Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #19

Node.js ile Script ve CLI Aracı Yazmak: process, util.parseArgs, Exit Code, readline, child_process ve Sinyaller

Mert Özen 15 Kas 2026 14 dk 8 görüntülenme
Node.js ile Script ve CLI Aracı Yazmak: process, util.parseArgs, Exit Code, readline, child_process ve Sinyaller

Node.js'te CLI aracı process.argv'yi util.parseArgs ile ayrıştırır, sonucu stdout'a, hataları stderr'e yazar, process.exit yerine process.exitCode ile çıkar ve dış komutları shell'siz execFile/spawn ile çağırır. Gerçek çıktılarla process.exit yüzünden hiç oluşmayan export dosyası, yazım hatasını yakalayan strict parseArgs, terminal yoksa soru sormayan onay, exec ile çalışan command injection ve SIGINT'te geçici dosyasını silen script.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), npm 11.20.0, TypeScript 7.0.2 ve Git ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) back office ekibi, gece çalışan bir export script'i yazmış. 100.000 sipariş satırını bir CSV (virgülle ya da noktalı virgülle ayrılmış değerler) dosyasına yazıp çıkıyor:

import { createWriteStream } from "node:fs";
import { mkdirSync } from "node:fs";

// Bir export script'i: 100.000 sipariş satırını CSV dosyasına yaz, sonra çık
mkdirSync("labs/19/out", { recursive: true });
const out = createWriteStream("labs/19/out/orders.csv");
for (let i = 1; i <= 100_000; i++) {
    out.write(`${1000 + i};9780441172719;1;349.90\n`);
}
console.log("export written");
process.exit(0);

Ekranda export written yazıyor. Dosyada kaç satır var?

node labs/19/guess.ts
node labs/19/count-lines.ts labs/19/out/orders.csv
export written
labs/19/out/orders.csv: does not exist

Dosya hiç oluşmadı. createWriteStream dosyayı asenkron açar ve write() çağrıları önce bellekteki buffer'a gider; process.exit(0) ise event loop'ta bekleyen her şeyi bırakıp process'i hemen bitirir. Dosya açılmış olsaydı bile durum pek farklı olmazdı: önce dosyanın açılmasını bekleyen bir sürümde yalnız ilk satır yazıldı. Düzeltme, stream'i kapatıp bitmesini beklemek ve process'i kendi hâline bırakmak:

const out = createWriteStream("labs/19/out/orders.csv");
for (let i = 1; i <= 100_000; i++) {
    out.write(`${1000 + i};9780441172719;1;349.90\n`);
}
out.end();
await finished(out);
console.log("export written");
process.exitCode = 0;
export written
labs/19/out/orders.csv: 100000 lines

Bu yazıda Node.js ile komut satırı aracı yazmanın parçalarını görüyoruz: process object'i, exit code'lar, util.parseArgs, readline, child_process ve sinyaller. Sonunda Bookstore'un bookstore-tools adlı bir CLI'ı olacak; .NET'te bunun karşılığı bir console uygulaması ve System.CommandLine ya da bir dotnet tool.

process: argv, env, exitCode, stdout ve stderr

Bir CLI'ın dış dünyayla konuştuğu her şey process'te. process.argv bir string array'i: ilk iki element node'un ve script'in yolu, senin argümanların process.argv.slice(2). process.env ortam değişkenleri; 02'de gördüğümüz --env-file bu object'i doldurur ve ASP.NET Core'daki configuration'ın aksine değerler hep string'dir.

Çıkış için iki yol var. process.exit(code) process'i o anda bitirir; process.exitCode = code ise yalnız çıkış kodunu belirler, process işini bitirdiğinde o kodla çıkar. Önce Tahmin Et'teki kayıp, ilkinin bedeliydi. Node dokümanı ayrıca process.stdout ve process.stderr'in davranışının hedefe göre değiştiğini söylüyor: dosyaya yazarken senkron, POSIX (Linux ve macOS gibi Unix türevi sistemlerin ortak standardı) sistemlerde pipe'a yazarken asenkron, Windows'ta pipe'a yazarken senkron. Yani console.log'dan hemen sonra process.exit() çağıran bir script, çıktısı | grep'e verildiğinde Linux'ta satır kaybedip Windows'ta kaybetmeyebilir. Kural: process.exit()'i yalnız gerçekten beklenmeyen bir durumda, yazacak bir şey kalmadığında kullan; normal akışta process.exitCode.

stdout ve stderr'in ayrı olmasının da bir anlamı var. stdout aracın sonucu içindir, başka bir programın girdisi olabilir; uyarılar, ilerleme bilgisi ve hatalar stderr'e gider. Exit code ise script'in başarılı olup olmadığını çağırana söyleyen tek sinyal: CI job'u, && ile zincirlenmiş komutlar ya da PowerShell'deki $LASTEXITCODE ona bakar. Yaygın düzen: 0 başarı, 1 iş başarısız, 2 hatalı kullanım. Ctrl+C ile, yani SIGINT (kesme sinyali) ile durdurulan bir process'in kodunu shell'ler 130 (128 + 2; 2, SIGINT'in numarası) olarak gösterir.

util.parseArgs ile Argümanlar

Argümanları elle ayrıştırmak yerine Node 20'den beri stable olan util.parseArgs var. Seçenekleri tipleriyle tanımlıyorsun, o da values ve positionals dönüyor. Bookstore'un ilk komutu, 08'de yazdığımız CSV içe aktarmayı bir CLI komutuna çeviriyor:

async function importBooks(args: string[], io: CliIo): Promise<number> {
    const { values, positionals } = parseArgs({
        args,
        allowPositionals: true,
        options: {
            out: { type: "string", short: "o", default: "data/catalog.json" },
            "dry-run": { type: "boolean", default: false },
            json: { type: "boolean", default: false },
            yes: { type: "boolean", short: "y", default: false },
        },
    });
    if (positionals.length !== 1) {
        throw new UsageError("import-books needs exactly one CSV file");
    }
    const [csvPath] = positionals;
    const { books, errors } = await readCatalogCsv(csvPath);

    // Sorunlar stderr'e, sonuç stdout'a: "--json > result.json" geçerli bir JSON olarak kalır
    for (const error of errors) {
        io.stderr.write(`${styleText("yellow", "skipped", { stream: io.stderr })} ${error}\n`);
    }
    const dryRun = values["dry-run"];
    if (!dryRun) {
        if ((await exists(values.out)) && !values.yes && !(await confirm(`${values.out} exists. Overwrite? (y/N) `, io))) {
            io.stderr.write("nothing written\n");
            return EXIT.failed;
        }
        await mkdir(path.dirname(values.out), { recursive: true });
        await writeFileAtomic(values.out, JSON.stringify(books, null, 2) + "\n");
    }

    if (values.json) {
        io.stdout.write(JSON.stringify({ file: csvPath, imported: books.length, skipped: errors.length, written: dryRun ? null : values.out }) + "\n");
    } else {
        const target = dryRun ? "would be imported (dry run)" : `imported into ${values.out}`;
        io.stdout.write(`${styleText("green", "done", { stream: io.stdout })} ${books.length} books ${target}, ${errors.length} rows skipped\n`);
    }
    return errors.length > 0 ? EXIT.failed : EXIT.ok;
}

strict varsayılan olarak açık: tanımlanmamış bir seçenek gelirse parseArgs ERR_PARSE_ARGS_UNKNOWN_OPTION koduyla hata fırlatıyor. main bu kodu ve kendi UsageError'ını yakalayıp kullanım bilgisini basıyor ve 2 dönüyor:

npx bookstore-tools import-books --dryrun data/catalog-sample.csv
echo "exit $?"
error: Unknown option '--dryrun'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--dryrun"
usage: bookstore-tools <command> [options]
  import-books <file.csv> [--out <file.json>] [--dry-run] [--json] [--yes]
  snapshot [--out <file.zip>] [--allow-dirty]
exit 2

Yazım hatası sessizce yok sayılmadı; "dry run" sanılıp gerçek bir içe aktarma yapılmadı. default değerler ve short takma adlar (-o, -y) da aynı tanımda. Alt komutları (import-books, snapshot) parseArgs bilmiyor; ilk argümana bakan bir switch bu iş için yeterli. Daha büyük araçlarda yardım metni üretimi, alt komut ağaçları ve otomatik tamamlama gerekiyorsa commander ya da yargs gibi paketler var; System.CommandLine'ın karşılıkları onlar.

--json seçeneği stdout ile stderr ayrımının neden önemli olduğunu gösteriyor:

npx bookstore-tools import-books data/catalog-sample.csv --dry-run --json > labs/19/out/result.json
echo "exit $?"
cat labs/19/out/result.json
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
exit 1
{"file":"data/catalog-sample.csv","imported":6,"skipped":2,"written":null}

Atlanan satırlar terminalde göründü, dosyaya yalnız geçerli bir JSON satırı yazıldı. Exit code 1, çünkü iki satır atlandı; CI'da bu komut kırmızı olur. Renkler için paket gerekmiyor: util.styleText 23.5'ten beri stable ve verdiğin stream'in terminal olup olmadığına, NO_COLOR ve FORCE_COLOR değişkenlerine bakıyor. Bu yüzden dosyaya yönlendirilen çıktıda renk kodu yok.

readline ile Onay Sormak

Hedef dosya varsa üzerine yazmadan önce sormak istiyoruz. node:readline/promises ile soru sormak tek satır; asıl karar, ne zaman sorulacağı:

// Yalnız terminalde sorar; CI'da ya da pipe'ın arkasında cevap verecek kimse yok, bu yüzden cevap "hayır"
async function confirm(question: string, io: CliIo): Promise<boolean> {
    if (!io.stdin.isTTY) {
        io.stderr.write(`${question.trim()} no terminal to ask, pass --yes\n`);
        return false;
    }
    const rl = createInterface({ input: io.stdin, output: io.stderr });
    try {
        const answer = await rl.question(question, { signal: io.signal });
        return /^y(es)?$/i.test(answer.trim());
    } finally {
        rl.close();
    }
}

CI'da ya da pipe'ın arkasında soruya cevap verecek kimse yok; orada beklemek, job'un timeout'a kadar asılı kalması demek. process.stdin.isTTY yalnız gerçek bir terminalde true. Önce terminal dışında, stdin'i boş bir girdiye bağlayarak:

npx bookstore-tools import-books data/catalog-sample.csv
echo "exit $?"
npx bookstore-tools import-books data/catalog-sample.csv < /dev/null
echo "exit $?"
npx bookstore-tools import-books data/catalog-sample.csv --yes
echo "exit $?"
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
done 6 books imported into data/catalog.json, 2 rows skipped
exit 1
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
data/catalog.json exists. Overwrite? (y/N) no terminal to ask, pass --yes
nothing written
exit 1
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
done 6 books imported into data/catalog.json, 2 rows skipped
exit 1

İlk çalıştırmada dosya yoktu, yazıldı. İkincide stdin bir terminal değildi; araç sormadı, hiçbir şey yazmadı ve 1 döndü. --yes ile üzerine yazdı. Üç çalıştırmada da exit code 1, çünkü örnek dosyadaki iki hatalı satır her seferinde atlanıyor. Aynı komut terminalde soruyor; cevap n:

skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
data/catalog.json exists. Overwrite? (y/N) n
nothing written
exit 1

Sorunun beklerken de iptal edilebilmesi için question'a signal veriyoruz; terminalde Ctrl+D'ye basılırsa question Aborted with Ctrl+D mesajıyla reject ediyor, bunu da denemelerde gördüm.

Bu davranışların hepsi test edilebilir, çünkü main stream'leri parametre olarak alıyor: testte PassThrough stream'leri veriyor, çıktıyı string'e topluyoruz. Child process yok, terminal yok:

// CLI'ı aynı process'te, yakalanan stream'lerle çalıştırır: child process yok, terminal yok
async function run(argv: string[]) {
    const stdout = new PassThrough();
    const stderr = new PassThrough();
    let out = "";
    let err = "";
    stdout.on("data", (chunk) => (out += chunk));
    stderr.on("data", (chunk) => (err += chunk));
    const code = await main(argv, { stdout, stderr, stdin: new PassThrough(), signal: new AbortController().signal });
    return { code, out, err };
}
▶ bookstore-tools
  ✔ rejects an unknown option with exit code 2 (0.822833ms)
  ✔ prints only JSON on stdout with --json (0.972125ms)
  ✔ does not overwrite without a terminal or --yes (1.40625ms)
✔ bookstore-tools (5.6755ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

package.json "bin" ve npx

Script'i node scripts/bookstore-tools.ts diye çalıştırmak yerine bir komut adı verelim. package.json'daki "bin" alanı komut adını dosyaya bağlıyor; dosyanın ilk satırı da shebang:

#!/usr/bin/env node
// Back office CLI'ı, package.json "bin" alanında kayıtlı: npx bookstore-tools <command>
import { main } from "../src/tools/cli.ts";

// Ctrl+C ve SIGTERM süreci yazmanın ortasında öldürmek yerine çalışan işi bir sinyalle iptal eder;
// ikinci Ctrl+C'nin artık listener'ı yok ve process'i hemen durdurur
const interrupted = new AbortController();
for (const name of ["SIGINT", "SIGTERM"] as const) {
    process.once(name, () => interrupted.abort(new Error(`received ${name}`)));
}

// process.exit() yerine exitCode: stdout'a ve dosyalara bekleyen yazmalar yine tamamlanır
process.exitCode = await main(process.argv.slice(2), {
    stdout: process.stdout,
    stderr: process.stderr,
    stdin: process.stdin,
    signal: interrupted.signal,
});
"bin": {
  "bookstore-tools": "scripts/bookstore-tools.ts"
}

npx bookstore-tools projenin içinde bu dosyayı çalıştırıyor; Windows'ta npm shebang'e bakıp bir .cmd ve .ps1 kılıfı üretiyor (Windows'ta bu adımı denemedim). Bir sınır var: Node, node_modules altındaki .ts dosyalarında type stripping yapmıyor. node_modules içine kopyalanmış bir .ts dosyasını çalıştırınca ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING aldım. Proje içinde çalışan bu araç, npm'e yayınlanıp npm install -g ile kurulursa çalışmaz; yayınlanacak bir CLI'ı 05'te anlattığımız gibi JavaScript'e derle.

child_process: execFile, spawn ve Shell'siz Çalıştırmak

İkinci komut, kodun commit'lenmiş hâlini bir zip dosyasına koyuyor. Zip'i Git'in kendisi üretiyor: git archive --format=zip. Böylece Windows'ta olmayan zip komutuna bağımlı kalmıyoruz:

// Argümanlar array olarak gider ve shell başlatılmaz: içlerindeki hiçbir şey yorumlanmaz
async function git(args: string[], signal?: AbortSignal): Promise<string> {
    const { stdout } = await execFileAsync("git", args, { signal, maxBuffer: 1024 * 1024 });
    return stdout.trim();
}

export interface GitSnapshot {
    file: string;
    commit: string;
    bytes: number;
}

// HEAD'deki commit'lenmiş kaynağı zip'ler; zip'i git yazdığı için Windows, Linux ve macOS'ta aynı çalışır
export async function createGitSnapshot(options: { out?: string; allowDirty: boolean; signal?: AbortSignal }): Promise<GitSnapshot> {
    const commit = await git(["rev-parse", "--short", "HEAD"], options.signal);
    const changes = await git(["status", "--porcelain"], options.signal);
    if (changes && !options.allowDirty) {
        throw new Error(`the working tree has ${changes.split("\n").length} uncommitted change(s); commit them or pass --allow-dirty`);
    }
    const file = options.out ?? `bookstore-api-${commit}.zip`;

    // git archive zip'i stdout'a yazar; bellekte biriktirilmek yerine dosyaya stream edilir
    const child = spawn("git", ["archive", "--format=zip", "HEAD"], { signal: options.signal, stdio: ["ignore", "pipe", "pipe"] });
    let stderr = "";
    child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
    try {
        const [, [code, signal]] = await Promise.all([pipeline(child.stdout, createWriteStream(file)), once(child, "close")]);
        if (code !== 0) {
            throw new Error(`git archive failed (${signal ?? `exit code ${code}`}): ${stderr.trim()}`);
        }
    } catch (err) {
        // Arkada yarım bir zip bırakma
        await rm(file, { force: true });
        throw err;
    }
    return { file, commit, bytes: (await stat(file)).size };
}

child_process'te dört ana fonksiyon var. execFile bir programı argüman array'iyle çalıştırır ve bittiğinde bütün çıktıyı verir; kısa çıktılar için uygun, maxBuffer'ı geçerse process'i öldürür. spawn aynı işi stream'lerle yapar; git archive'ın megabaytlarca çıktısı 09'daki pipeline ile doğrudan dosyaya akıyor. exec komutu bir string olarak shell'e verir; fork ise yeni bir Node process'i açıp aralarında mesaj kanalı kurar. execFile ve spawn bir signal da alıyor: sinyal iptal edilince child process durduruluyor; Ctrl+C'de Git de bizimle birlikte duruyor. Exit code'u close event'inden okuyoruz, çünkü bu event stdout ve stderr kapandıktan sonra geliyor.

npx bookstore-tools snapshot
npx bookstore-tools snapshot --allow-dirty --out labs/19/out/bookstore-api.zip
error: the working tree has 5 uncommitted change(s); commit them or pass --allow-dirty
exit 1
done labs/19/out/bookstore-api.zip (commit b78afc1, 167 KB)
exit 0

Neden exec değil? Çünkü komut satırını bir shell yorumluyor ve kullanıcıdan gelen değer bir komuta dönüşebiliyor:

// Kullanıcı girdisinden gelen bir branch adı (CLI argümanı, web formu...)
const branch = "main; echo INJECTED: this line was run by the shell";

// exec: tek bir string, bir shell'e verilir (/bin/sh, Windows'ta cmd.exe); ';' ikinci bir komut başlatır
const viaShell = await execAsync(`git log --oneline -1 ${branch}`);
console.log("exec:\n" + viaShell.stdout.trim());

// execFile: shell yok; değerin tamamı git için tek bir argüman
try {
    await execFileAsync("git", ["log", "--oneline", "-1", branch]);
} catch (err) {
    console.log("execFile:\n" + (err as { stderr: string }).stderr.trim().split("\n")[0]);
}
exec:
b78afc1 18 HTTP client
INJECTED: this line was run by the shell
execFile:
fatal: invalid object name 'main; echo INJECTED'.

exec ile ;'den sonrası ikinci bir komut olarak çalıştı; bu bir command injection (kullanıcı girdisinin komut olarak çalıştırılması). Burada zararsız bir echo; aynı yere rm ya da bir indirme komutu da gelebilirdi. Windows'ta cmd.exe ;'yi ayraç saymaz ama & aynı işi görür. execFile ve shell'siz spawn değeri tek bir argüman olarak verdi, Git de böyle bir branch bulamadı. Node 24'ten beri spawn ya da execFile'a hem argüman array'i hem shell: true vermek de runtime deprecation uyarısı (DEP0190) veriyor; argümanlar escape edilmeden birleştiriliyor. Windows'a özgü bir not: .bat ve .cmd dosyaları shell'siz çalışamıyor; npm gibi bir komutu Windows'ta çağırman gerekiyorsa dokümanın önerdiği yol cmd.exe /c ile çalıştırmak, argümanları da kendin doğrulamak.

Sinyaller: SIGINT ve SIGTERM

Ctrl+C process'e SIGINT gönderir, docker stop ve Kubernetes SIGTERM (sonlandırma sinyali). Hiç listener yoksa Node anında çıkar. Bunu, bir export'u parça parça yazan bir script'le gördük; bir saniye sonra process'e SIGINT gönderdim (terminalde Ctrl+C'nin yaptığı şey):

exit 130
labs/19/out/export.csv.tmp: 12000 lines

Yarım kalmış 12.000 satırlık bir geçici dosya ortada kaldı. Listener eklendiğinde varsayılan davranış kalkıyor ve kapanışı sen yönetiyorsun. Script sinyali bir AbortController'a bağlıyor; bekleme iptal olunca stream'i kapatıp geçici dosyayı siliyor ve 130 ile çıkıyor:

if (handle) {
    for (const name of ["SIGINT", "SIGTERM"] as const) {
        process.once(name, () => stop.abort(new Error(`received ${name}`)));
    }
}
export cancelled after 12000 lines (received SIGINT), temp file removed
exit 130
labs/19/out/export.csv.tmp: does not exist

bookstore-tools aynı kalıbı kullanıyor ve process.once ile kaydediyor: ilk Ctrl+C işi düzgünce iptal ediyor, listener kalmadığı için ikinci Ctrl+C process'i anında durduruyor. Sıkışmış bir aracı kapatamamak, kullanıcıyı terminali kapatmaya zorlar. 14'teki API'nin graceful shutdown'ı da aynı fikir; orada amaç açık request'leri bitirmekti. Windows'ta Ctrl+C SIGINT olarak geliyor; SIGTERM'e listener eklenebiliyor ama doküman Windows'ta desteklenmediğini yazıyor.

Üretimde Dikkat

Script'ler de kod: bookstore-tools aynı repo'da, aynı type check ve testlerle duruyor. Tek seferlik script'leri de en azından labs/ ya da scripts/ altında tut; prod'da çalıştırılan ve kimsenin bulamadığı bir script kötü bir sürpriz.

CI'da renk ve etkileşim olmasın. styleText terminal yoksa renk kullanmıyor; onay soran her komutun bir --yes seçeneği olsun ve stdin terminal değilse beklemesin.

Path'leri path.join ile kur, Git'ten gelen satırları /\r?\n/ ile böl: Windows'ta satır sonları \r\n olabilir. Child process çağırırken komutun Windows'ta da var olduğundan emin ol; git ve node var, zip, grep ve sed yok.

Sık Düşülen Tuzaklar

exec'e kullanıcı girdisini string olarak vermek. Command injection'a kapı açar; execFile ya da spawn ile argüman array'i kullan.

İşi bitirmeden process.exit() çağırmak. Stream'deki ve stdout'taki veri kaybolur; process.exitCode ile çık.

Hataları stdout'a yazmak. Çıktıyı okuyan program onları veri sanar.

Her hatada 0 ile çıkmak. CI başarısız bir import'u yeşil gösterir.

SIGINT'i yakalayıp hiç çıkmamak. Kullanıcının elinde process'i durduracak bir yol kalmaz.

Küçük Bir Deneme

★☆☆ import-books'a --quiet seçeneği ekle: atlanan satırları yazmasın ama exit code değişmesin. Bir test yaz.

★★☆ bookstore-tools changes komutunu yaz: spawn("git", ["log", "--format=%an"])'in stdout'unu readline'ın createInterface'iyle satır satır oku ve yazar başına commit sayısını yazdır.

★★★ import-books'u 09'daki gibi stream ile okumaya çevir ve SIGINT'te yarım kalan geçici dosyayı silerek 130 ile çıktığını bir child process testiyle doğrula (spawn ile başlat, child.kill("SIGINT") gönder).

Sıradaki yazıda node:crypto'ya geçiyoruz: ödeme sağlayıcısından gelen webhook'un HMAC (gizli bir anahtarla üretilen mesaj doğrulama kodu) imzasını doğrulayacak, parolaları scrypt ile saklayacak ve hassas bir alanı AES (yaygın kullanılan simetrik şifreleme algoritması) ile şifreleyeceğiz.