Mert Özen Further With Every Line
Developer Tools Productivity and Automation — #03

Collecting Project Commands in One Place: justfile, Makefile, npm scripts, dotnet tools and C# File-based Apps

Mert Özen Nov 15, 2026 13 min 4 views
Collecting Project Commands in One Place: justfile, Makefile, npm scripts, dotnet tools and C# File-based Apps

Collect project commands in a justfile in the repo instead of the README, and make CI call the same recipes. With real output we show Bookstore's justfile (parameters, .env, starting the API and Angular together with [parallel], PowerShell on Windows), a comparison with Makefiles and npm scripts, the local .NET tool manifest, dnx's -- pitfall, and a seed tool written as a .NET 10 file-based app.

Version note: The commands in this post were run with .NET 10 (SDK 10.0.404), just 1.59.0, GNU Make 3.81, Node.js 24, Angular 22.2, PowerShell 7.7, System.CommandLine 3.0.0 and dotnet-ef 10.0.15.

Guess First

You create a new project with the Angular 22 CLI and, without touching any file, run prettier --check src with the Prettier settings the CLI put into the project. How many files get flagged as "not formatted"?

In Bookstore, the online bookstore used as the shared example across this site's series, we learned the answer by accident: on the day we first ran the format check through the same command as CI. The answer is in the "Production Notes" section, with real output.

Commands Scattered Across the README

After a while Bookstore's README looked like this: dotnet run --project src/Bookstore.Api for the API, cd web/bookstore-web && npm start in a separate terminal for Angular, two separate commands for tests, two more for the format check, and a Bash script for seed data. Every newcomer copied this list and wrote their own aliases. CI ran yet another list.

What we want is a single entry point: just dev, just test, just db-reset. Commands that both developers and CI call, that are versioned with the repo and that work under the same name on Windows and macOS. There are five tools for this: Makefiles, just, npm scripts, local .NET tools and the C# file-based apps that came with .NET 10. They don't all do the same job; below we see which one belongs where.

justfile: Bookstore's Single Entry Point

just is a tool inspired by Make, but it is not a build system; it only runs commands. It is a single binary, installed with winget install --id Casey.Just --exact on Windows and with Homebrew on macOS. The justfile at the repo root:

# Bookstore commands. Run `just` to see the list.

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"

# List all recipes
default:
    @just --list

# Restore .NET tools, NuGet and npm packages
[group('setup')]
restore:
    dotnet tool restore
    dotnet restore
    npm --prefix {{ web }} ci

# Build the API and the Angular app
[group('build')]
build:
    dotnet build --no-restore
    npm --prefix {{ web }} run build

# Run .NET and Angular tests
[group('test')]
test:
    dotnet test --no-restore
    npm --prefix {{ web }} test -- --watch=false

# Check formatting without changing files
[group('test')]
format-check:
    dotnet format --verify-no-changes --no-restore
    npm --prefix {{ web }} run format:check

# What CI runs, in the same order
[group('test')]
ci: restore build format-check test

# Start the API and the Angular dev server together
[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 }}

# Write seed data (default: 10 books)
[group('data')]
seed count="10":
    dotnet run tools/seed.cs -- --count {{ count }}

# Delete and rewrite the seed data
[group('data')]
[confirm("Delete data/books.json and write new seed data?")]
db-reset count="10":
    dotnet run tools/seed.cs -- --reset --count {{ count }}

The comment right above a recipe becomes its description in just --list. just without arguments runs the first recipe, which prints the list:

$ 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

The descriptions in the repo's file are in English, so the output is in English too. The [group] attribute splits the list into groups; api and web, marked [private], don't appear in the list, but dev can call them. just searches upward from the current folder for the justfile and runs recipes in that file's folder, so just test works from inside web/bookstore-web/src too. --dry-run shows a command without running it:

$ just --dry-run seed 3
dotnet run tools/seed.cs -- --count 3

Unlike Make, just doesn't look at file timestamps; every recipe runs on every call. An unknown recipe name or a circular dependency is reported as an error before anything runs.

Recipe Parameters, .env and Running in Parallel

seed count="10" is a parameter with a default; just seed 50 changes it. db-reset asks for confirmation before running because of the [confirm] attribute, which makes deleting the seed data by accident harder. To skip the question in CI, use just --yes db-reset.

set dotenv-load loads the .env file at the root as environment variables. .env.example goes into the repo, while .env is in .gitignore:

# Copy to .env and change locally; .env is not committed.
BOOKSTORE_API_PORT=5080
BOOKSTORE_WEB_PORT=4200

Because I work on a shared machine, the ports in my own .env are 52095 and 52096. env("BOOKSTORE_API_PORT", "5080") reads the variable from the environment and falls back to the default. BOOKSTORE_SEED_FILE, defined with export, goes into the environment of every recipe; the API finds the seed file through it. The / operator joins paths:

$ just --evaluate BOOKSTORE_SEED_FILE
…/data/books.json

The dev recipe has no body of its own, only the api and web dependencies. The [parallel] attribute starts both at once. The Angular dev server runs with a proxy file that forwards /api requests to the API, taking the target port from the same environment variable:

// Dev server forwards /api calls to the local Bookstore.Api.
export default {
  '/api': {
    target: `http://127.0.0.1:${process.env.BOOKSTORE_API_PORT ?? '5080'}`,
    secure: false,
  },
};

After writing five books with just seed 5 and running just dev, the output of both processes flows mixed into the same terminal:

$ 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

A request to the API through Angular's port shows that the proxy and the seed file work:

$ curl http://127.0.0.1:52096/api/books/9780000000019
{"isbn":"9780000000019","title":"Dune","author":"Frank Herbert","price":350.90}

Ctrl+C stops both processes together. Mixed output is the known cost of [parallel]; if you want separate logs for long-running processes, two terminals or the IDE's compound launch configuration are more comfortable (covered in the debug configuration post of the IDEs series).

Windows and macOS: The Shell Difference

just hands every recipe line to a shell; the default is sh -cu. On Windows, sh only comes with something like Git for Windows. That's why the justfile starts with a set shell line carrying the [windows] attribute: on Windows recipes run with PowerShell 7, elsewhere with sh. The older set windows-shell setting is now listed as deprecated in the documentation.

For this to work, recipes have to mean the same thing in both shells. Bookstore's recipes contain only dotnet, npm and just calls; no rm -rf, no export, no && chains and no $(...). To check on macOS that the recipes also work in PowerShell, I switched the shell from the command line:

$ 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

Deleting files, creating folders or reading JSON are written differently per shell. Those jobs belong not in a recipe but in a program that behaves the same on both platforms; the seed tool below does exactly that.

When Makefiles and npm Scripts?

Make is on every Linux and macOS machine, which is its biggest advantage. But it was designed as a build system: targets are treated as file names, and a target that isn't a file stops working the day a folder with the same name appears unless it is marked .PHONY. Recipe lines must also start with a tab. When the editor turns the tab into spaces:

$ make test
Makefile:2: *** missing separator.  Stop.

Make doesn't come with Windows by default. Passing parameters (make seed COUNT=50) and loading .env are possible but written by hand. If your team already uses a Makefile, keeping it as a list of simple targets that only run on Linux and macOS is reasonable.

npm scripts are the natural home for the Angular side. start, build and test in package.json come from the CLI; I added the format and format:check scripts. just calls these scripts with npm --prefix web/bookstore-web run ..., so an Angular developer can keep typing npm test inside web/bookstore-web. Making npm scripts the entry point for .NET commands, on the other hand, means making .NET developers install Node.js.

Makefilejustnpm scripts
Install on Windowsextra tool neededsingle binary (winget)comes with Node.js
Parametersmake seed COUNT=50just seed 50npm run seed -- 50
Loading .envby handset dotenv-loadby hand or a package
Recipe listnonejust --listnpm run
File timestampsyesnono

Local .NET Tools and dnx

Bookstore's recipes also call .NET tools such as dotnet ef. These aren't installed globally; they are pinned with a manifest file in the repo. With the .NET 10 SDK, dotnet new tool-manifest wrote the file to the repo root as dotnet-tools.json, while the documentation's examples still show the .config/dotnet-tools.json path. The CLI looks for both; pick one for the team:

{
  "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

That's why the first line of just restore is dotnet tool restore. .NET 10 also brought dotnet tool exec and its shortcut dnx: they download and run a tool without installing it. In a folder with a manifest they use the version from the manifest. On my first try I passed the tool's own option directly:

$ dnx dotnet-ef --version
Required argument missing for option: '--version'.

dnx takes --version as its own option (the one that picks which package version to run). Arguments meant for the tool have to be separated with --:

$ dnx dotnet-ef -- --version
Entity Framework Core .NET Command-line Tools
10.0.15

dnx is good for one-off jobs: trying a diagnostic tool, a tool that runs once in CI. The tools the team uses every day should stay in the manifest; the version is written in the repo and everyone runs the same one.

C# Instead of Bash: A Seed Tool as a File-Based App

File-based apps, new in .NET 10, run a single .cs file without a project file. By the decision table in the "Your First Bash Script" post of the Bash Scripting series, a tool that has to run on two operating systems, produces JSON and takes several options is no longer a good candidate for Bash. Bookstore's seed tool, 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;

The #:package directive adds a NuGet package from inside the file; there are also #:sdk, #:property and #:project directives. Thanks to the shebang on the first line, after chmod +x it runs directly on Linux and macOS as ./tools/seed.cs --count 3; on Windows you use dotnet run tools/seed.cs, which is why the recipe calls the second form. System.CommandLine generates the help text itself:

$ 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

Two things surprised me. First, because the file is inside the repo, it inherits the root Directory.Build.props; the analyzers and the "warnings are errors" setting apply here too. In the first version I had written i.ToString("D3"):

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.

That's a good thing: tool code goes through the same rules as product code. Second, file-based apps target native AOT by default. That's why I use a source generator (SeedJson) for JSON. Outside the repo, in a folder without Directory.Build.props, I tried a small file with reflection-based JsonSerializer.Serialize; the build only gave warnings, and the program stopped at run time:

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.

If you're going to use a package that needs reflection, add #:property PublishAot=false to the file. The build output isn't written to the repo but to a folder in the user profile, so no bin/ or obj/ appears in the repo. With packages already downloaded, the first run including the build took 0.8 seconds for me, and later runs finished in 0.14 seconds. There is an --artifacts-path option to change the output location.

Production Notes

The most important rule for a task runner: CI calls the same recipe. Bookstore's GitHub Actions workflow (covered in detail in the GitHub Actions series) installs just on the runner and runs one line:

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

The answer to Guess First shows why this rule pays off. The first time I ran just ci locally, the format-check step stopped on three files that the Angular CLI generated and I never touched:

$ 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

If CI ran only ng build, nobody would have noticed; on the first day someone ran npm run format, a surprise diff would have appeared in other people's files. I fixed it once with npm run format and committed it; the step passes in later runs. Before pushing the workflow file I checked it with actionlint, and the output was clean. Pinning the just version in the workflow matters too; the version you use locally and the one in CI should be the same.

Common Pitfalls

The first is feeding CI separate commands. If the workflow has dotnet test while the justfile has dotnet test --no-restore plus the Angular tests, one day they drift apart and the "works on my machine" conversation starts. Every CI step should be a recipe.

The second is piling logic into recipes. When you see a ten-line if, a loop or JSON handling, move it into a program under tools/ and let the recipe only call it. That way the Windows and macOS difference is solved in one place.

The third is committing .env. .env.example is committed, .env goes into .gitignore. If you're putting a real secret into .env, reading the secret from a secret manager with just's dotenv-command setting is safer. The fourth is not saying in the README that just has to be installed: a newcomer sees just: command not found on day one. In the last post of the series we automate that installation too.

A Small Experiment

★☆☆ Count the commands in your own project's README and move them into a justfile. Ask a teammate whether the just --list output explains enough for a newcomer.

★★☆ Add a --format csv option to seed.cs and write a second path that writes CSV output. Look at what the analyzers catch on the first build.

★★★ Add a recipe named api-test to the justfile that calls /api/books with curl or PowerShell's Invoke-RestMethod while just dev is running. Write two bodies for the same name with the [windows] and [unix] attributes and try both.

Project commands are now in one place. But the machine that runs them was still set up by hand: shell settings, .gitconfig, the prompt, aliases. In the next post we keep these settings as dotfiles in Git, sync them between machines with chezmoi or GNU Stow, and avoid leaking secrets while doing it.