Proje Komutlarını Tek Yerde Toplamak: justfile, Makefile, npm scripts, dotnet tool ve C# file-based app
Proje komutlarını README yerine repo'daki bir justfile'da topla ve CI'ın da aynı recipe'yi çağırmasını sağla. Bookstore'un justfile'ını (parametre, .env, [parallel] ile API ve Angular'ı birlikte başlatma, Windows için PowerShell), Makefile ve npm scripts karşılaştırmasını, yerel .NET tool manifest'ini, dnx'in -- tuzağını ve .NET 10 file-based app ile yazılmış seed aracını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki komutlar .NET 10 (SDK 10.0.404), just 1.59.0, Make 3.81 (GNU sürümü), Node.js 24, Angular 22.2, PowerShell 7.7, System.CommandLine 3.0.0 ve dotnet-ef 10.0.15 ile çalıştırılmıştır.
Önce Tahmin Et
Angular 22 CLI ile yeni bir proje oluşturuyorsun ve hiçbir dosyaya dokunmadan, CLI'ın projeye koyduğu Prettier ayarıyla prettier --check src çalıştırıyorsun. Kaç dosya "biçimlendirilmemiş" diye işaretlenir?
Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) bu sorunun cevabını tesadüfen öğrendik: format kontrolünü ilk kez CI ile aynı komuttan çalıştırdığımız gün. Cevap "Üretimde Dikkat" bölümünde, gerçek çıktıyla.
README'deki Dağınık Komutlar
Bookstore'un README'si bir süre sonra şuna benziyordu: API için dotnet run --project src/Bookstore.Api, Angular için ayrı bir terminalde cd web/bookstore-web && npm start, testler için iki ayrı komut, format kontrolü için iki komut daha, seed verisi için bir Bash script'i. Her yeni gelen bu listeyi kopyalayıp kendi alias'larını yazıyordu. CI ise başka bir listeyi çalıştırıyordu.
İstediğimiz şey tek bir giriş noktası: just dev, just test, just db-reset. Hem geliştiricinin hem CI'ın çağırdığı, repo ile birlikte versiyonlanan, Windows ve macOS'ta aynı adla çalışan komutlar. Bunun için beş araç var: Makefile, just, npm scripts, yerel .NET tool'ları ve .NET 10 ile gelen C# file-based app'ler. Hepsi aynı işi yapmıyor; aşağıda hangisinin nereye düştüğünü göreceğiz.
justfile: Bookstore'un Tek Giriş Noktası
just, Make'ten esinlenmiş ama build sistemi olmayan, yalnız komut çalıştıran bir araç. Tek bir binary; Windows'ta winget install --id Casey.Just --exact, macOS'ta Homebrew ile kurulur. Repo kökündeki justfile:
# Bookstore komutları. Listeyi görmek için `just` yaz.
set dotenv-load
[windows]
set shell := ["pwsh", "-NoLogo", "-NoProfile", "-Command"]
web := "web/bookstore-web"
api_port := env("BOOKSTORE_API_PORT", "5080")
web_port := env("BOOKSTORE_WEB_PORT", "4200")
export BOOKSTORE_SEED_FILE := justfile_directory() / "data" / "books.json"
# Tüm recipe'leri listele
default:
@just --list
# .NET tool'larını, NuGet ve npm paketlerini geri yükle
[group('setup')]
restore:
dotnet tool restore
dotnet restore
npm --prefix {{ web }} ci
# API'yi ve Angular uygulamasını build et
[group('build')]
build:
dotnet build --no-restore
npm --prefix {{ web }} run build
# .NET ve Angular testlerini çalıştır
[group('test')]
test:
dotnet test --no-restore
npm --prefix {{ web }} test -- --watch=false
# Dosyaları değiştirmeden formatı kontrol et
[group('test')]
format-check:
dotnet format --verify-no-changes --no-restore
npm --prefix {{ web }} run format:check
# CI'ın çalıştırdığı adımlar, aynı sırayla
[group('test')]
ci: restore build format-check test
# API'yi ve Angular dev server'ı birlikte başlat
[group('run')]
[parallel]
dev: api web
[private]
api:
dotnet run --project src/Bookstore.Api --urls http://127.0.0.1:{{ api_port }}
[private]
web:
npm --prefix {{ web }} start -- --host 127.0.0.1 --port {{ web_port }}
# Seed verisi yaz (varsayılan: 10 kitap)
[group('data')]
seed count="10":
dotnet run tools/seed.cs -- --count {{ count }}
# Seed verisini silip yeniden yaz
[group('data')]
[confirm("Delete data/books.json and write new seed data?")]
db-reset count="10":
dotnet run tools/seed.cs -- --reset --count {{ count }}
Recipe'nin hemen üstündeki yorum, just --list çıktısında açıklama olur. Parametresiz just ilk recipe'yi çalıştırır; o da listeyi basıyor:
$ just
Available recipes:
default # List all recipes
[build]
build # Build the API and the Angular app
[data]
db-reset count="10" # Delete and rewrite the seed data
seed count="10" # Write seed data (default: 10 books)
[run]
dev # Start the API and the Angular dev server together
[setup]
restore # Restore .NET tools, NuGet and npm packages
[test]
ci # What CI runs, in the same order
format-check # Check formatting without changing files
test # Run .NET and Angular tests
Repo'daki dosyada açıklamalar İngilizce olduğu için çıktı da İngilizce. [group] attribute'u listeyi gruplara ayırıyor, [private] ile işaretlenen api ve web listede görünmüyor ama dev onları çağırabiliyor. just, justfile'ı bulunduğun klasörden yukarı doğru arar ve recipe'leri o dosyanın klasöründe çalıştırır; web/bookstore-web/src içindeyken de just test çalışır. --dry-run komutu çalıştırmadan gösterir:
$ just --dry-run seed 3
dotnet run tools/seed.cs -- --count 3
Make'ten farklı olarak just dosya zaman damgalarına bakmaz, her recipe her çağrıda çalışır. Bilinmeyen bir recipe adı ya da döngüsel bağımlılık, hiçbir şey çalışmadan önce hata olarak raporlanır.
Recipe Parametreleri, .env ve Paralel Çalıştırma
seed count="10" varsayılanı olan bir parametre; just seed 50 ile değişir. db-reset ise [confirm] attribute'u yüzünden çalışmadan önce onay soruyor; yanlışlıkla seed verisini silmek zorlaşıyor. CI'da onay sorusunu atlamak için just --yes db-reset kullanılır.
set dotenv-load kökteki .env dosyasını ortam değişkeni olarak yükler. Repo'ya .env.example giriyor, .env ise .gitignore'da:
# .env olarak kopyala ve yerelde değiştir; .env commit edilmez.
BOOKSTORE_API_PORT=5080
BOOKSTORE_WEB_PORT=4200
Paylaşımlı bir makinede çalıştığım için kendi .env dosyamda portlar 52095 ve 52096. env("BOOKSTORE_API_PORT", "5080") değişkeni ortamdan okuyor, yoksa varsayılanı kullanıyor. export ile tanımlanan BOOKSTORE_SEED_FILE her recipe'nin ortamına giriyor; API seed dosyasını buradan buluyor. / operatörü yolları birleştiriyor:
$ just --evaluate BOOKSTORE_SEED_FILE
…/data/books.json
dev recipe'sinin kendi gövdesi yok, yalnız api ve web bağımlılıkları var. [parallel] attribute'u ikisini aynı anda başlatıyor. Angular dev server'ı /api isteklerini API'ye yönlendiren bir proxy dosyasıyla çalışıyor, hedef portu yine aynı ortam değişkeninden alıyor:
// Dev server /api çağrılarını yereldeki Bookstore.Api'ye yönlendirir.
export default {
'/api': {
target: `http://127.0.0.1:${process.env.BOOKSTORE_API_PORT ?? '5080'}`,
secure: false,
},
};
just seed 5 ile beş kitap yazıp just dev çalıştırınca iki sürecin çıktısı aynı terminalde karışık akıyor:
$ just dev
dotnet run --project src/Bookstore.Api --urls http://127.0.0.1:52095
npm --prefix web/bookstore-web start -- --host 127.0.0.1 --port 52096
...
> ng serve --host 127.0.0.1 --port 52096
Using launch settings from src/Bookstore.Api/Properties/launchSettings.json...
Building...
...
Application bundle generation complete. [0.489 seconds] - 2026-11-15T12:17:34.626Z
...
➜ Local: http://127.0.0.1:52096/
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://127.0.0.1:52095
Angular'ın portu üzerinden API'ye giden istek, proxy'nin ve seed dosyasının çalıştığını gösteriyor:
$ curl http://127.0.0.1:52096/api/books/9780000000019
{"isbn":"9780000000019","title":"Dune","author":"Frank Herbert","price":350.90}
Ctrl+C iki süreci birlikte durduruyor. Çıktıların karışması [parallel]'in bilinen bedeli; uzun süre çalışan süreçlerde log'ları ayırmak istiyorsan iki terminal ya da IDE'nin compound launch configuration'ı daha rahat (IDE'ler serisinin debug configuration yazısında anlatıldı).
Windows ve macOS: Shell Farkı
just her recipe satırını bir shell'e verir; varsayılan sh -cu. Windows'ta sh ancak Git for Windows gibi bir kurulumla gelir. Bu yüzden justfile'ın başında [windows] attribute'lu bir set shell satırı var: Windows'ta recipe'ler PowerShell 7 ile, diğer sistemlerde sh ile çalışıyor. Eski sürümlerdeki set windows-shell ayarı belgede artık deprecated olarak geçiyor.
Bu ayarın işe yaraması için recipe'lerin iki shell'de de aynı anlama gelmesi gerekiyor. Bookstore'un recipe'lerinde yalnız dotnet, npm ve just çağrıları var; rm -rf, export, && zinciri ya da $(...) yok. Recipe'lerin PowerShell'de de çalıştığını macOS'ta denemek için shell'i komut satırından değiştirdim:
$ just --shell pwsh --shell-arg -NoLogo --shell-arg -NoProfile --shell-arg -Command seed 2
dotnet run tools/seed.cs -- --count 2
Wrote 2 books to data/books.json
Dosya silmek, klasör oluşturmak, JSON okumak gibi işler shell'e göre farklı yazılır. Bunları recipe'ye değil, iki platformda da aynı çalışan bir programa koymak gerekiyor; aşağıdaki seed aracı tam bunu yapıyor.
Makefile ve npm scripts Ne Zaman?
Make her Linux ve macOS makinede var, bu onun en büyük avantajı. Ama build sistemi olarak tasarlandı: hedefler dosya adı sayılır, dosya olmayan hedefler .PHONY ile işaretlenmezse aynı adda bir klasör oluştuğu gün recipe çalışmayı bırakır. Recipe satırları da tab ile başlamak zorunda. Editör tab'ı boşluğa çevirince:
$ make test
Makefile:2: *** missing separator. Stop.
Windows'ta Make varsayılan olarak gelmiyor. Parametre geçirmek (make seed COUNT=50) ve .env yüklemek mümkün ama elle yazılıyor. Zaten Makefile kullanan bir ekipsen, dosyayı yalnız Linux ve macOS'ta çalışan, basit hedeflerden oluşan bir listede tutmak makul.
npm scripts Angular tarafının doğal yeri. package.json'daki start, build, test CLI'dan geliyor; format ve format:check scriptlerini ben ekledim. just bu script'leri npm --prefix web/bookstore-web run ... ile çağırıyor; Angular geliştiricisi web/bookstore-web içinde npm test yazmaya devam edebiliyor. npm scripts'i .NET komutlarının giriş noktası yapmak ise .NET geliştiricisine Node.js kurdurmak demek.
| Makefile | just | npm scripts | |
|---|---|---|---|
| Windows'ta kurulum | ek araç gerekir | tek binary (winget) | Node.js ile gelir |
| Parametre | make seed COUNT=50 | just seed 50 | npm run seed -- 50 |
.env yükleme | elle | set dotenv-load | elle ya da paket |
| Recipe listesi | yok | just --list | npm run |
| Dosya zaman damgası | var | yok | yok |
Yerel .NET Tool'ları ve dnx
Bookstore'un recipe'leri dotnet ef gibi .NET tool'larını da çağırıyor. Bunlar global kurulmaz, repo'daki bir manifest dosyasıyla sabitlenir. .NET 10 SDK'da dotnet new tool-manifest dosyayı repo köküne dotnet-tools.json adıyla yazdı; belgenin örnekleri ise hâlâ .config/dotnet-tools.json yolunu gösteriyor. CLI ikisini de arıyor, ekip içinde birini seçmek yeter:
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.15",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
$ dotnet tool restore
Tool 'dotnet-ef' (version '10.0.15') was restored. Available commands: dotnet-ef
Restore was successful.
$ dotnet ef --version
Entity Framework Core .NET Command-line Tools
10.0.15
just restore'un ilk satırı bu yüzden dotnet tool restore. .NET 10 bir de dotnet tool exec ve onun kısa yolu dnx'i getirdi: tool'u kurmadan indirip çalıştırıyor. Manifest olan bir klasörde manifest'teki sürümü kullanıyor. İlk denememde tool'un kendi option'ını doğrudan verdim:
$ dnx dotnet-ef --version
Required argument missing for option: '--version'.
--version'ı dnx kendi option'ı sanıyor (hangi paket sürümünün çalışacağını seçen option). Tool'a gidecek argümanları -- ile ayırmak gerekiyor:
$ dnx dotnet-ef -- --version
Entity Framework Core .NET Command-line Tools
10.0.15
dnx tek seferlik işler için iyi: bir tanılama aracını denemek, CI'da bir kez çalışacak bir tool. Ekibin her gün kullandığı tool'lar ise manifest'te durmalı; sürüm repo'da yazılı olur ve herkes aynı sürümü çalıştırır.
Bash Yerine C#: File-based App ile Seed Aracı
.NET 10'la gelen file-based app'ler tek bir .cs dosyasını proje dosyası olmadan çalıştırıyor. Bash Scripting serisinin "İlk Bash Script'i" yazısındaki karar tablosuna göre, iki işletim sisteminde çalışması gereken, JSON üreten ve birkaç option alan bir araç artık Bash için iyi bir aday değil. Bookstore'un seed aracı tools/seed.cs:
#!/usr/bin/env dotnet
#:package System.CommandLine@3.0.0
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
Option<int> countOption = new("--count")
{
Description = "Number of books to write",
DefaultValueFactory = _ => 10,
};
Option<FileInfo> outputOption = new("--output")
{
Description = "Seed file to write",
DefaultValueFactory = _ => new FileInfo(Path.Combine("data", "books.json")),
};
Option<bool> resetOption = new("--reset")
{
Description = "Delete the seed file before writing",
};
RootCommand root = new("Writes seed data for Bookstore's in-memory catalog.")
{
countOption,
outputOption,
resetOption,
};
root.SetAction(result =>
{
FileInfo output = result.GetValue(outputOption)!;
if (result.GetValue(resetOption) && output.Exists)
{
output.Delete();
Console.WriteLine($"Deleted {output.Name}");
}
(string Title, string Author)[] classics =
[
("Dune", "Frank Herbert"),
("Emma", "Jane Austen"),
("The Hobbit", "J. R. R. Tolkien"),
("Middlemarch", "George Eliot"),
("Moby-Dick", "Herman Melville"),
];
Random random = new(42);
List<SeedBook> books = [.. Enumerable.Range(1, result.GetValue(countOption)).Select(i =>
{
(string title, string author) = classics[(i - 1) % classics.Length];
return new SeedBook(Isbn13("978000000" + i.ToString("D3", CultureInfo.InvariantCulture)), title, author, random.Next(150, 450) + 0.90m);
})];
output.Directory!.Create();
File.WriteAllText(output.FullName, JsonSerializer.Serialize(books, SeedJson.Default.ListSeedBook));
Console.WriteLine($"Wrote {books.Count} books to {Path.GetRelativePath(Environment.CurrentDirectory, output.FullName)}");
});
return root.Parse(args).Invoke();
static string Isbn13(string first12)
{
int sum = first12.Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum();
return first12 + ((10 - sum % 10) % 10);
}
internal sealed record SeedBook(string Isbn, string Title, string Author, decimal Price);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true)]
[JsonSerializable(typeof(List<SeedBook>))]
internal sealed partial class SeedJson : JsonSerializerContext;
#:package direktifi NuGet paketini dosyanın içinden ekliyor; #:sdk, #:property ve #:project direktifleri de var. İlk satırdaki shebang sayesinde Linux ve macOS'ta chmod +x sonrası ./tools/seed.cs --count 3 ile doğrudan çalışıyor; Windows'ta dotnet run tools/seed.cs kullanılıyor, recipe de bu yüzden ikinci biçimi çağırıyor. System.CommandLine yardım metnini kendisi üretiyor:
$ dotnet run tools/seed.cs -- --help
Description:
Writes seed data for Bookstore's in-memory catalog.
Usage:
seed [options]
Options:
--count <count> Number of books to write [default: 10]
--output <output> Seed file to write [default: data/books.json]
--reset Delete the seed file before writing
-?, -h, --help Show help and usage information
--version Show version information
İki şey beni şaşırttı. Birincisi, dosya repo içinde olduğu için kökteki Directory.Build.props'u miras alıyor; analyzer'lar ve "uyarı = hata" ayarı burada da geçerli. İlk sürümde i.ToString("D3") yazmıştım:
tools/seed.cs(52,50): error CA1305: The behavior of 'int.ToString(string)' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'int.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
The build failed. Fix the build errors and run again.
Bu iyi bir şey: araç kodu da ürün koduyla aynı kurallardan geçiyor. İkincisi, file-based app'ler varsayılan olarak native AOT (ahead-of-time; programı çalıştırmadan önce makine koduna derlemek) hedefliyor. JSON için source generator (SeedJson) kullanmamın sebebi bu. Repo dışında, Directory.Build.props olmayan bir klasörde reflection tabanlı JsonSerializer.Serialize ile küçük bir dosya denedim; derleme yalnız uyarı verdi, program ise çalışırken durdu:
refl.cs(3,19): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. ...
refl.cs(3,19): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. ...
Unhandled exception. System.InvalidOperationException: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property.
Reflection gerektiren bir paket kullanacaksan dosyaya #:property PublishAot=false ekle. Build çıktısı repo'ya değil kullanıcı profilindeki bir klasöre yazılıyor, bu yüzden repo'da bin/ ya da obj/ oluşmuyor. Paketler indirilmişken ilk çalıştırma derlemeyle birlikte bende 0,8 saniye sürdü, sonraki çalıştırmalar 0,14 saniyede bitti. Çıktı yerini değiştirmek için --artifacts-path option'ı var.
Üretimde Dikkat
Task runner'ın en önemli kuralı: CI aynı recipe'yi çağırır. Bookstore'un GitHub Actions workflow'u (GitHub Actions serisinde ayrıntısıyla) runner'a just'ı kurup tek satır çalıştırıyor:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: extractions/setup-just@v4
with:
just-version: 1.59.0
- run: just ci
Önce Tahmin Et'in cevabı bu kuralın faydasını gösteriyor. just ci'ı ilk kez yerelde çalıştırdığımda format-check adımı, Angular CLI'ın ürettiği ve hiç dokunmadığım üç dosyada durdu:
$ just ci
...
npm --prefix web/bookstore-web run format:check
Checking formatting...
[warn] src/app/app.config.ts
[warn] src/index.html
[warn] src/main.ts
[warn] Code style issues found in 3 files. Run Prettier with --write to fix.
error: recipe `format-check` failed on line 41 with exit code 1
CI'da yalnız ng build çalışıyor olsaydı bunu kimse görmezdi; her geliştiricinin npm run format çalıştırdığı ilk gün, başkasının dosyalarında sürpriz bir diff çıkardı. Bir kez npm run format ile düzeltip commit ettim, sonraki koşularda adım geçiyor. Workflow dosyasını push etmeden önce actionlint ile doğruladım, çıktı temizdi. just sürümünü workflow'da sabitlemek de önemli; yerelde kullandığın sürümle CI'daki sürüm aynı olmalı.
Sık Düşülen Tuzaklar
Birincisi CI'ı ayrı komutlarla beslemek. Workflow'da dotnet test, justfile'da dotnet test --no-restore ve Angular testleri varsa, bir gün ikisi ayrışır ve "bende geçiyor" konuşması başlar. CI'daki her adım bir recipe olmalı.
İkincisi recipe'lerin içine mantık yığmak. On satırlık bir if, döngü ya da JSON işleme gördüğünde onu tools/ altında bir programa taşı; recipe yalnız çağırsın. Böylece Windows ve macOS farkı tek yerde çözülür.
Üçüncüsü .env'i commit etmek. .env.example commit edilir, .env .gitignore'a girer. .env'e gerçek bir secret koyuyorsan just'ın dotenv-command ayarıyla secret'ı bir secret manager'dan okumak daha güvenli. Dördüncüsü just'ı kurmayı README'de söylememek: yeni gelen ilk gün just: command not found görür. Serinin son yazısında bu kurulumu da otomatikleştireceğiz.
Küçük Bir Deneme
★☆☆ Kendi projende README'deki komutları sayıp bir justfile'a taşı. just --list çıktısının yeni gelen biri için yeterince açıklayıcı olup olmadığını bir ekip arkadaşına sor.
★★☆ seed.cs'e --format csv option'ı ekle ve CSV (virgülle ayrılmış değerler) çıktısını yazan ikinci bir yol yaz. Analyzer'ların ilk derlemede ne yakaladığına bak.
★★★ justfile'a api-test adında, just dev çalışırken curl ya da PowerShell'in Invoke-RestMethod'u ile /api/books'u çağıran bir recipe ekle. [windows] ve [unix] attribute'larıyla aynı ada iki gövde yaz ve ikisini de dene.
Proje komutları artık tek yerde. Ama bunları çalıştıran makine hâlâ elle kurulmuş: shell ayarları, .gitconfig, prompt, alias'lar. Bir sonraki yazıda bu ayarları dotfiles olarak Git'te tutmayı, chezmoi ya da GNU Stow ile makineler arasında senkronlamayı ve bunu yaparken secret sızdırmamayı anlatıyoruz.