// Projekt Einstieg · etwa 4 Stunden Arbeit
Logbuch-Kommandozeile
Sie bauen logbook, ein Journal für die Kommandozeile. node main.js add "Text" --tag x speichert einen Eintrag mit Schlagwort in einer JSON-Datei, list zeigt die Einträge, list --tag x filtert sie, und export out.json kopiert sie in eine andere Datei. Das Projekt verbindet die ganze Einsteigerstufe: Argumente mit util.parseArgs, Dateien mit node:fs/promises, Daten auf stdout und Meldungen auf stderr, aussagekräftige Exit-Codes, Konfiguration aus der Umgebung und eine node:test-Suite, die beweist, dass alles funktioniert.
Was das fertige Programm kann
- node main.js add <text> [--tag <tag>]... hängt einen Eintrag {id, date, text, tags} an die Datendatei an, legt die Datei an, falls sie fehlt, und gibt Added entry <id>. aus. Die id ist um eins größer als die bisher höchste; das Datum ist der Tag als YYYY-MM-DD; --tag (kurz -t) darf mehrmals vorkommen.
- node main.js list gibt je Eintrag eine Zeile aus, in der Reihenfolge des Hinzufügens: id, Datum, Text und, wenn vorhanden, die Schlagwörter in eckigen Klammern, getrennt durch zwei Leerzeichen, etwa 1 2026-09-30 Went running [sport]. Ohne Einträge gibt es No entries. aus.
- node main.js list --tag <tag> zeigt nur die Einträge mit diesem Schlagwort (mit jedem genannten, wenn --tag mehrmals vorkommt).
- node main.js export <file> schreibt alle Einträge als JSON in <file> und gibt Exported <n> entries to <file>. aus.
- Die Argumente wertet util.parseArgs aus. Ein unbekannter Befehl, eine unbekannte Option oder ein fehlendes Argument gibt logbook: <Grund> und die Hilfe auf stderr aus, nichts auf stdout, und endet mit Exit-Code 2.
- Eine Datendatei, die kein gültiges JSON enthält oder die list oder export nicht findet, ergibt eine klare Meldung logbook: … auf stderr und Exit-Code 1. Nichts wird überschrieben.
- Die Datendatei ist LOGBOOK_FILE aus der Umgebung (damit funktioniert node --env-file=.env main.js), sonst logbook.json im aktuellen Ordner.
- Die Logik steht in logbook.js als run(args, {file, now}); die Funktion gibt {code, out, err} zurück, statt selbst auszugeben, damit die Tests sie mit einer temporären Datei und einer festen Uhr aufrufen können. main.js gibt nur das Ergebnis aus und setzt process.exitCode.
Aufbau der Startdateien
- main.js
- Der Befehl: liest LOGBOOK_FILE, ruft run() mit den Argumenten auf, gibt out und err aus und setzt process.exitCode. Vollständig vorgegeben.
- logbook.js
- Ihre Arbeit: die Optionen für parseArgs, load(), format() und run().
- main.test.js
- Die Abnahmetests (node:test). Ausführen mit node --test.
- package.json
- {"type": "module"}, damit .js-Dateien ES-Module sind. Im Starter-Download enthalten.
- learnrun.js
- Die Hilfsdatei des Kurses zum Ausführen von main.js; die Tests dieses Projekts brauchen sie nicht. Im Starter-Download enthalten.
Etappen
Etappe 1
Argumente auswerten
Beschreiben Sie --tag (kurz -t, mehrfach) in OPTIONS, rufen Sie parseArgs mit allowPositionals auf und machen Sie aus einem Auswertungsfehler oder einem unbekannten Befehl logbook: <Grund> plus Hilfe auf stderr, mit Exit-Code 2.
Prüfungen, die nach dieser Etappe bestehen:
- Ein unbekannter Befehl gibt die Hilfe auf stderr aus und endet mit 2
- Eine unbekannte Option meldet parseArgs, und das Programm endet mit 2
Etappe 2
Hinzufügen und auflisten
Schreiben Sie load() für eine vorhandene Datei, dann add (mit der nächsten id und dem heutigen Datum, legt die Datei an) und list mit format().
Prüfungen, die nach dieser Etappe bestehen:
- add legt die Datei an und speichert den Eintrag mit seinen Schlagwörtern
- list gibt jeden Eintrag in einer eigenen Zeile aus, in der Reihenfolge des Hinzufügens
Etappe 3
Nach Schlagwort filtern
Behalten Sie nur die Einträge, die jedes mit --tag genannte Schlagwort tragen, und geben Sie No entries. aus, wenn keiner übrig bleibt.
Prüfungen, die nach dieser Etappe bestehen:
- list --tag zeigt nur die Einträge mit diesem Schlagwort
Etappe 4
Exportieren
Schreiben Sie die Einträge eingerückt in die nach export genannte Datei und melden Sie, wie viele es waren.
Prüfungen, die nach dieser Etappe bestehen:
- export schreibt jeden Eintrag in die genannte JSON-Datei
Etappe 5
Klar scheitern
Melden Sie eine beschädigte und (bei list und export) eine fehlende Datei als logbook: <Meldung> auf stderr mit Exit-Code 1, und führen Sie das ganze Programm einmal mit node main.js aus.
Prüfungen, die nach dieser Etappe bestehen:
- Eine beschädigte Datendatei ergibt eine klare Meldung auf stderr und Exit-Code 1
- list ohne Datendatei ergibt eine klare Meldung und Exit-Code 1
- node main.js liest LOGBOOK_FILE und setzt den Exit-Code
Dieses Projekt nutzt Teile von Node.js, die nicht im Browser laufen, deshalb bauen Sie es auf Ihrem Computer.
Auf dem eigenen Rechner bauen
Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie Node.js 24 LTS und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:
Startdateien als eine .zip herunterladen (Startdateien, main.test.js, package.json und learnrun.js)node --testpackage.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.
learnrun.js herunterladenmain.js
import {run} from './logbook.js';
// The data file comes from LOGBOOK_FILE (for example through --env-file), or logbook.json.
const file = process.env.LOGBOOK_FILE ?? 'logbook.json';
const result = await run(process.argv.slice(2), {file});
process.stdout.write(result.out);
process.stderr.write(result.err);
process.exitCode = result.code;
logbook.js
import {readFile, writeFile} from 'node:fs/promises';
import {parseArgs} from 'node:util';
export const USAGE = `Usage:
node main.js add <text> [--tag <tag>]...
node main.js list [--tag <tag>]
node main.js export <file>`;
// Milestone 1: describe --tag (short -t, may be given more than once) for parseArgs.
const OPTIONS = {};
// Milestone 2 and 5: read and parse the JSON file. A missing file is an empty
// logbook only when missingOk is true; otherwise throw an Error with a clear message.
export async function load(file, {missingOk = false} = {}) {
return [];
}
// Milestone 2: one line per entry, such as "1 2026-09-30 Went running [sport]".
export function format(entry) {
return '';
}
// Runs one command and returns {code, out, err} instead of printing.
// Milestones 1 to 5: parse args, then handle add, list and export.
export async function run(args, {file, now = () => new Date()}) {
return {code: 0, out: '', err: ''};
}
package.json
{
"type": "module"
}
Abnahmetests
Das Projekt ist fertig, wenn jede Prüfung in main.test.js besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {mkdtempSync, readFileSync, writeFileSync, existsSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {run} from './logbook.js';
// A fresh folder per test, and a fixed clock, so dates are predictable.
const folder = () => mkdtempSync(join(tmpdir(), 'logbook-'));
const now = () => new Date('2026-09-30T12:00:00Z');
test('Ein unbekannter Befehl gibt die Hilfe auf stderr aus und endet mit 2', async () => {
const r = await run(['remove'], {file: join(folder(), 'log.json'), now});
assert.equal(r.code, 2, `der Exit-Code war ${r.code}`);
assert.match(r.err, /Usage:/, `stderr war ${JSON.stringify(r.err)}`);
assert.equal(r.out, '', `stdout sollte leer bleiben, war aber ${JSON.stringify(r.out)}`);
});
test('Eine unbekannte Option meldet parseArgs, und das Programm endet mit 2', async () => {
const r = await run(['list', '--colour'], {file: join(folder(), 'log.json'), now});
assert.equal(r.code, 2, `der Exit-Code war ${r.code}`);
assert.match(r.err, /--colour/, `stderr sollte die Option nennen, war aber ${JSON.stringify(r.err)}`);
});
test('add legt die Datei an und speichert den Eintrag mit seinen Schlagwörtern', async () => {
const file = join(folder(), 'log.json');
const r = await run(['add', 'Fixed the login bug', '--tag', 'work', '-t', 'bug'], {file, now});
assert.equal(r.code, 0, `der Exit-Code war ${r.code}; stderr: ${r.err}`);
assert.equal(r.out, 'Added entry 1.\n', `stdout war ${JSON.stringify(r.out)}`);
const saved = JSON.parse(readFileSync(file, 'utf8'));
assert.deepEqual(saved, [{id: 1, date: '2026-09-30', text: 'Fixed the login bug', tags: ['work', 'bug']}], `die Datei enthält ${JSON.stringify(saved)}`);
});
test('list gibt jeden Eintrag in einer eigenen Zeile aus, in der Reihenfolge des Hinzufügens', async () => {
const file = join(folder(), 'log.json');
await run(['add', 'Read the fs docs', '--tag', 'learn'], {file, now});
await run(['add', 'Went running'], {file, now});
const r = await run(['list'], {file, now});
assert.equal(r.code, 0, `der Exit-Code war ${r.code}; stderr: ${r.err}`);
assert.equal(r.out, '1 2026-09-30 Read the fs docs [learn]\n2 2026-09-30 Went running\n', `stdout war ${JSON.stringify(r.out)}`);
});
test('list --tag zeigt nur die Einträge mit diesem Schlagwort', async () => {
const file = join(folder(), 'log.json');
await run(['add', 'Read the fs docs', '--tag', 'learn'], {file, now});
await run(['add', 'Went running', '--tag', 'sport'], {file, now});
const r = await run(['list', '--tag', 'sport'], {file, now});
assert.equal(r.out, '2 2026-09-30 Went running [sport]\n', `stdout war ${JSON.stringify(r.out)}`);
const none = await run(['list', '--tag', 'music'], {file, now});
assert.equal(none.out, 'No entries.\n', `ohne Treffer war stdout ${JSON.stringify(none.out)}`);
});
test('export schreibt jeden Eintrag in die genannte JSON-Datei', async () => {
const dir = folder();
const file = join(dir, 'log.json');
await run(['add', 'First'], {file, now});
await run(['add', 'Second', '--tag', 'x'], {file, now});
const out = join(dir, 'out.json');
const r = await run(['export', out], {file, now});
assert.equal(r.code, 0, `der Exit-Code war ${r.code}; stderr: ${r.err}`);
assert.equal(r.out, `Exported 2 entries to ${out}.\n`, `stdout war ${JSON.stringify(r.out)}`);
assert.ok(existsSync(out), 'die Exportdatei wurde nicht geschrieben');
const exported = JSON.parse(readFileSync(out, 'utf8'));
assert.deepEqual(exported.map((e) => e.text), ['First', 'Second'], `der Export enthält ${JSON.stringify(exported)}`);
});
test('Eine beschädigte Datendatei ergibt eine klare Meldung auf stderr und Exit-Code 1', async () => {
const file = join(folder(), 'log.json');
writeFileSync(file, '[{"id": 1,');
const r = await run(['list'], {file, now});
assert.equal(r.code, 1, `der Exit-Code war ${r.code}`);
assert.equal(r.err, `logbook: ${file} is not valid JSON\n`, `stderr war ${JSON.stringify(r.err)}`);
});
test('list ohne Datendatei ergibt eine klare Meldung und Exit-Code 1', async () => {
const file = join(folder(), 'log.json');
const r = await run(['list'], {file, now});
assert.equal(r.code, 1, `der Exit-Code war ${r.code}`);
assert.equal(r.err, `logbook: no logbook at ${file}; add an entry first\n`, `stderr war ${JSON.stringify(r.err)}`);
});
test('node main.js liest LOGBOOK_FILE und setzt den Exit-Code', () => {
const dir = folder();
const env = {...process.env, LOGBOOK_FILE: join(dir, 'log.json')};
const main = fileURLToPath(new URL('./main.js', import.meta.url));
const added = spawnSync(process.execPath, [main, 'add', 'From the shell'], {env, encoding: 'utf8'});
assert.equal(added.status, 0, `add endete mit ${added.status}; stderr: ${added.stderr}`);
assert.equal(added.stdout, 'Added entry 1.\n', `add gab ${JSON.stringify(added.stdout)} aus`);
const bad = spawnSync(process.execPath, [main, 'dance'], {env, encoding: 'utf8'});
assert.equal(bad.status, 2, `ein unbekannter Befehl endete mit ${bad.status}`);
assert.equal(bad.stdout, '', `ein unbekannter Befehl gab ${JSON.stringify(bad.stdout)} auf stdout aus`);
});
Das fertige Programm starten
node main.js add "Went running" --tag sportReferenzlösung
Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.
main.js
import {run} from './logbook.js';
// The data file comes from LOGBOOK_FILE (for example through --env-file), or logbook.json.
const file = process.env.LOGBOOK_FILE ?? 'logbook.json';
const result = await run(process.argv.slice(2), {file});
process.stdout.write(result.out);
process.stderr.write(result.err);
process.exitCode = result.code;
logbook.js
import {readFile, writeFile} from 'node:fs/promises';
import {parseArgs} from 'node:util';
export const USAGE = `Usage:
node main.js add <text> [--tag <tag>]...
node main.js list [--tag <tag>]
node main.js export <file>`;
const OPTIONS = {tag: {type: 'string', short: 't', multiple: true}};
// Reads the entries. A missing file is an empty logbook only when missingOk is true
// (add creates it); otherwise it is an error, and so is a corrupt file.
export async function load(file, {missingOk = false} = {}) {
let text;
try {
text = await readFile(file, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
if (missingOk) return [];
throw new Error(`no logbook at ${file}; add an entry first`);
}
let entries;
try {
entries = JSON.parse(text);
} catch {
throw new Error(`${file} is not valid JSON`);
}
if (!Array.isArray(entries)) throw new Error(`${file} does not hold a list of entries`);
return entries;
}
// One line per entry: id, date, text, then the tags in brackets.
export function format(entry) {
const tags = entry.tags.length ? ` [${entry.tags.join(', ')}]` : '';
return `${entry.id} ${entry.date} ${entry.text}${tags}`;
}
// Runs one command. Returns what to print and the exit code instead of printing,
// so tests can call it directly.
export async function run(args, {file, now = () => new Date()}) {
const usage = (message) => ({code: 2, out: '', err: `logbook: ${message}\n${USAGE}\n`});
let parsed;
try {
parsed = parseArgs({args, options: OPTIONS, allowPositionals: true});
} catch (error) {
return usage(error.message);
}
const [command, ...rest] = parsed.positionals;
const tags = parsed.values.tag ?? [];
try {
if (command === 'add') {
const text = rest.join(' ').trim();
if (!text) return usage('add needs the text of the entry');
const entries = await load(file, {missingOk: true});
const id = entries.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
entries.push({id, date: now().toISOString().slice(0, 10), text, tags});
await writeFile(file, JSON.stringify(entries, null, 2) + '\n');
return {code: 0, out: `Added entry ${id}.\n`, err: ''};
}
if (command === 'list') {
const entries = (await load(file)).filter((entry) => tags.every((tag) => entry.tags.includes(tag)));
if (entries.length === 0) return {code: 0, out: 'No entries.\n', err: ''};
return {code: 0, out: entries.map(format).join('\n') + '\n', err: ''};
}
if (command === 'export') {
if (rest.length !== 1) return usage('export needs exactly one file name');
const entries = await load(file);
await writeFile(rest[0], JSON.stringify(entries, null, 2) + '\n');
return {code: 0, out: `Exported ${entries.length} entries to ${rest[0]}.\n`, err: ''};
}
return usage(command ? `unknown command "${command}"` : 'no command given');
} catch (error) {
return {code: 1, out: '', err: `logbook: ${error.message}\n`};
}
}
Weiterentwickeln
- Fügen Sie einen Befehl delete <id> hinzu, der mit Exit-Code 1 scheitert, wenn es die id nicht gibt.
- Schreiben Sie die Datendatei atomar: erst in eine temporäre Datei im selben Ordner, dann per rename über die alte (Modul B3).
- Ergänzen Sie list um --since YYYY-MM-DD und fügen Sie einen Befehl stats hinzu, der die Einträge je Schlagwort zählt.
- Legen Sie in package.json ein Skript "logbook" an und probieren Sie es mit node --run logbook -- list aus.
Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.
Quellen
- Util: util.parseArgs (Node.js v24 documentation)
- File system: fsPromises.readFile and fsPromises.writeFile (Node.js v24 documentation)
- Errors: common system errors (Node.js v24 documentation)
- Process: process.exitCode (Node.js v24 documentation)
- Test runner (Node.js v24 documentation)
Zuletzt geprüft am 30. September 2026
Zurück zum Kurs Node.js