/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

The Toolchain: mise, moon & just

CrowdVision is a polyglot monorepo: TypeScript services, a Python AI agent, a Rust contracts service, and shared JavaScript tooling. To keep that manageable on any operating system, the developer experience rests on three cooperating layers, each with a single, well-defined responsibility.

LayerResponsibilityConfiguration
justThe human-facing command entrypoint. Every workflow is a just <recipe>. It also drives the tasks the layers below do not — Docker, Kubernetes, and documentation.Justfile
miseTool versions. The single source of truth for which Node, Python, Rust, uv, and moon you run, on every platform..mise.toml
moonPer-project task orchestration — build, test, lint, audit, deps — with result caching and affected-detection..moon/

Dependency installation is a moon task like any other (:deps, :relock). There is no separate script layer: everything is mise plus moon, fronted by just.

flowchart TD
  dev([Developer]) --> just["just (recipe)"]
  just --> mise["mise exec — injects pinned tools onto PATH"]
  mise --> moon["moon — task graph, caching, affected"]
  moon --> tasks["build, test, lint, audit, deps, deps-check, relock"]
  cfgA["mise.toml"] -->|versions| mise
  cfgB["moon/workspace.yml"] -->|package list| moon

The problem this solves

Before this setup, tools were resolved from whatever happened to be on the developer’s PATH. That is fragile: a globally installed Node (from nvm) or Rust (from rustup) silently shadows the versions the project expects, and a tool with no global fallback — notably uv — simply fails:

'uv' is not recognized as an internal or external command

The governing principle of this page follows from that failure: never trust the ambient PATH. Resolve every tool through mise, and let one configuration file be the source of truth for each concern.


just — the task entrypoint

just is a command runner: a Justfile of named recipes that wrap the longer commands a developer would otherwise memorise. It is the only layer a contributor interacts with directly; mise and moon sit behind it.

Two design points matter:

Run just --list to see every recipe. The recipe catalogue is documented in Running the Application.


mise — the toolchain manager

mise pins every language runtime and CLI to an exact version, per project, across platforms. The pins live in .mise.toml at the repository root:

[tools]
node = "24"
python = "3.14.7"
rust = "1.98.1"
go = "1.27.1"
"ubi:moonrepo/moon" = "2.5.4"
uv = "0.12.10"
istioctl = "1.31.0"

Running mise install once after cloning fetches every tool at the pinned version into mise’s own store, independent of anything else on the machine.

How tools are resolved

mise can place those tools on the PATH in two ways. The project is designed to work with or without the first.

MechanismWhen it is usedHow
Shell activationInteractive work in your own terminal.mise activate pwsh | Out-String | Invoke-Expression (PowerShell), or mise activate bash/zsh. Added to a shell profile, the pinned tools are then on PATH directly.
mise exec --Scripts, CI, and the Justfile — anywhere that must work without assuming activation.mise exec -- <command> resolves the tool from .mise.toml and runs it, injecting the pinned tools onto PATH for that command and every child process it spawns.

Activation is optional

You do not need to activate mise for just to work. Every recipe that calls a managed tool already wraps it in mise exec --, so a fresh clone with only just and mise installed runs immediately. Activation is purely a convenience for invoking tools by hand.

The same rule inside moon

just launches moon as mise exec -- moon …, and mise exec exports the pinned tools onto PATH for the command and every child process it spawns. Every task moon runs — npm ci, cargo fetch, uv sync — therefore inherits the same resolved toolchain. moon’s own toolchain management stays off: mise remains the single source of truth for versions.

Diagnosing “command not found”

If any tool reports “command not found” (classically uv), something bypassed mise. Run it through mise exec -- <tool>, or activate mise in your shell. The tool is installed; it is simply not on the un-activated PATH.


moon — the task orchestrator

moon runs the per-project tasks — build, test, lint, lint-fix, audit, deps, deps-check, relock — across the whole monorepo, with two capabilities a hand-rolled script set never had:

One project registry

Every package is registered once in .moon/workspace.yml. This is the single list of packages in the repository; nothing else maintains a parallel copy.

projects:
  frontend: "frontend"
  digital-twin: "backend/digital-twin"
  # … the other services …
  dashboard: "backend/dashboard"
  sensor-simulator: "simulators/sensor-simulator"
  # Support packages: deps-only, no build/test/lint
  eslint-config: "tooling/eslint-config"
  acceptance: "backend/acceptance"

Tasks defined once per language

Rather than repeat the same build/test/lint configuration in a dozen moon.yml files, the pipeline is defined once per language in .moon/tasks/*.yml, each scoped with inheritedBy so it applies only to projects of that language.

FileApplies toTasks defined
.moon/tasks/typescript.ymllanguage: typescriptbuild, test, lint, lint-fix, deps, relock, audit
.moon/tasks/python.ymllanguage: pythontest, lint, lint-fix, deps, relock, audit
.moon/tasks/rust.ymllanguage: rustbuild, test, lint, deps (cargo fetch), deps-check (cargo check), audit
.moon/tasks/go.ymllanguage: gobuild (go build ./...), test (go test ./...), lint (a gofmt -l check then go vet ./... — no golangci-lint in this environment yet), deps (go mod download). registry and tenancy add their own test-integration (go test -tags=integration, testcontainers). No audit task yet.
.moon/tasks/javascript.ymllanguage: javascriptdeps, relock only — for support packages

A language file is scoped by its inheritedBy block:

# .moon/tasks/typescript.yml
inheritedBy:
  language: 'typescript'
tasks:
  test:
    command: 'npm test'
    inputs: ['src/**/*', '__tests__/**/*', 'package.json', '/schemas/fixtures/**/*', '/schemas/json/**/*']
  audit:
    command: 'npm audit --audit-level=high --omit=dev'
    options: { cache: false }
  # … build, lint, lint-fix, deps …

Every language’s test inputs include /schemas/fixtures/**/* and /schemas/json/**/* (/ is workspace-relative). Contract tests read those files, and moon hashes a task by its inputs: without them a fixture edit replays a cached pass and --affected selects nothing. Rust’s test also lists tests/**/*, where every crate keeps its conformance tests.

Why npm test for the Python service

The agent package.json proxies npm test to uv run pytest and npm run lint to uv run ruff … && pyright. This gives moon one uniform task interface across languages while the real work still runs through uv.

Each project’s moon.yml is minimal

Because the pipeline is inherited, a standard service’s entire moon configuration is a single line declaring its language; the tasks come from the language file. Projects declare only what genuinely differs:

Error: tablebyrows

Cannot call function tablebyrows(...) with arguments (...):
Unresolved reference: gitignore

.tablebyrows {.headers}
    - - `frontend`
      - Uses Vite/Vitest: overrides `build` inputs and `test` (to `npm run test:unit`), with `mergeArgs: replace` / `mergeInputs: replace` so the overrides replace the inherited values rather than appending — which is why its `test` re-lists the fixture and schema inputs itself.
    - - `sensor-simulator`, `aq-simulator`
      - Each asserts `schemas/fixtures/ingest-batch.json`: they hand-build the batch telemetry hand-parses, so the fixture is the only thing holding the three languages to one shape. The Python one overrides `test`/`lint` to call `uv` directly, because the inherited tasks shell out to `npm` and the root `.gitignore` ignores every `package.json`.
    - - `ap-simulator`
      - Serves a fake ubus endpoint rather than producing telemetry, so it has no fixture to assert and excludes the inherited `test` task; its CI leg runs `smoke.py` instead.
    - - `tooling`, `eslint-config`
      - Tagged `language: 'javascript'` so they inherit only `deps`. (`acceptance` is `language: 'python'` and excludes the inherited `test`/`lint` tasks: its pytest suite runs via `just test integration` against a composed stack, never as a moon `:test`.)
    - - `claims-gateway`, `tenancy`, `registry`, `provisioner`
... (1 more lines)

Inspecting the graph

These read-only commands print resolved configuration without running anything:

mise exec -- moon task digital-twin:test   # one task: command, inputs, inheritance
mise exec -- moon query projects           # every project moon knows (id + language)
mise exec -- moon query tasks              # which projects expose each task

How the layers cooperate

The payoff of the design is that each fact lives in exactly one place.

QuestionAnswered by
Which version of Node, uv, or Rust?.mise.toml
Which packages exist in the repository?.moon/workspace.yml
How is a TypeScript project tested, linted, or audited?.moon/tasks/typescript.yml
How are a project’s dependencies installed or relocked?The same per-language file — its deps / relock tasks

Crucially, install and clean-install are moon runs, not a parallel hand-written package list:

just setup install        # mise exec -- moon run :deps
just setup clean-install  # mise exec -- moon run :relock, then :deps
just setup deps-check     # mise exec -- moon run :deps :deps-check

So just setup install installs exactly the packages moon knows about; no drift is possible, and unchanged projects are skipped by moon’s affected-detection.

Why deps and relock aren’t cached

Both carry options.cache: false: npm ci wipes and reinstalls node_modules, and relock rewrites a lockfile — neither is a cacheable artifact. They still get moon’s fan-out, output grouping, and project graph. relock uses script: rather than command: because it chains rm -rf with npm install.


Adding a new service or package

Thanks to the single registry, onboarding a package is a one-place change:

  1. Register it in .moon/workspace.yml under projects:.
  2. Add a minimal moon.yml in the package with its language: (and any genuine task overrides).
  3. It now flows automatically into whichever tasks its language file defines (:build / :test / :lint / :deps, and :audit for every language except go), and into just setup install / just setup clean-install via :deps / :relock. Nothing else to register.

Choose the language deliberately

typescript / python / rust / go each inherit their own full build/test/lint pipeline — use the one matching the package’s actual scripts. Use javascript for support packages that need dependency installs but have no build/test/lint (such as tooling).


Troubleshooting

SymptomCause and fix
'uv' is not recognized / command not foundA tool was called outside mise. Use mise exec -- <tool>, or activate mise. The tool is installed; it is not on the un-activated PATH.
link.exe not found during cargo (Windows)Rust needs the MSVC linker. Install Visual Studio Build Tools with the “Desktop development with C++” workload. (macOS needs xcode-select --install; Linux needs build-essential.)
just test all / just lint all re-runs everythingExpected on the first run. The second run replays from moon’s cache. Use --affected to scope to changed projects.
A new service is not tested or installedIt is not registered in .moon/workspace.yml, or its moon.yml is missing or has the wrong language. Verify with mise exec -- moon query projects.