Developer Guide
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.
| Layer | Responsibility | Configuration |
|---|---|---|
just | The 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 |
mise | Tool versions. The single source of truth for which Node, Python, Rust, uv, and moon you run, on every platform. | .mise.toml |
moon | Per-project task orchestration — build, test, lint, audit, deps — with result caching and affected-detection. | .moon/ |
Underneath, two small Node scripts (scripts/install.mjs, scripts/clean.mjs) handle dependency installation, the one job that does not fit moon’s model. Everything else 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"] mise --> scripts["scripts/install.mjs, clean.mjs"] moon --> tasks["build, test, lint, audit, deps"] scripts --> query["moon query projects"] cfgA["mise.toml"] -->|versions| mise cfgB["moon/workspace.yml"] -->|package list| moon cfgB -->|package list| query
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 commandThe 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 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:
Justfile selects bash on Unix and PowerShell on Windows, so the same recipe name works on every machine.mise exec -- (see below). Tools that mise does not manage — docker, k3d, kubectl — are invoked directly.Run just --list to see every recipe. The recipe catalogue is documented in Running the Application.
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.12"
rust = "stable"
"ubi:moonrepo/moon" = "latest"
uv = "latest"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.
mise can place those tools on the PATH in two ways. The project is designed to work with or without the first.
| Mechanism | When it is used | How |
|---|---|---|
| Shell activation | Interactive 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. |
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 Node scripts that just launches (install, clean) spawn tools of their own. They route those through the same gate with a small helper, scripts/lib/mise.mjs:
// Resolve a command's tool through mise — unless a mise-provided environment is
// already present (the Justfile launches scripts under `mise exec`, which exports
// __MISE_DIFF), in which case we avoid wrapping it twice.
export function withMise(cmd) {
const inside = process.env.__MISE_DIFF || process.env.MISE_SHELL;
return inside ? cmd : `mise exec -- ${cmd}`;
}A script therefore works both when launched via just (already under mise exec) and when run directly as node scripts/install.mjs on a shell that never activated mise.
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 runs the per-project tasks — build, test, lint, lint-fix, audit, deps — across the whole monorepo, with two capabilities a hand-rolled script set never had:
--affected runs a task only for the projects touched on the current branch.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"
twin-service: "backend/twin-service"
# … the other services …
contracts-service: "backend/contracts-service"
sensor-simulator: "simulators/sensor-simulator"
# Support packages: deps-only, no build/test/lint
eslint-config: "tooling/eslint-config"
tests: "backend/tests"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.
| File | Applies to | Tasks defined |
|---|---|---|
.moon/tasks/typescript.yml | language: typescript | build, test, lint, lint-fix, deps, audit |
.moon/tasks/python.yml | language: python | test, lint, lint-fix, deps, audit |
.moon/tasks/rust.yml | language: rust | build, test, lint, deps, audit |
.moon/tasks/go.yml | language: go | build (go build ./...), test (go test ./...), lint (go vet ./... — no golangci-lint in this environment yet), deps (go mod download). No audit task yet. |
.moon/tasks/javascript.yml | language: javascript | deps 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']
audit:
command: 'npm audit --audit-level=high --omit=dev'
options: { cache: false }
# … build, lint, lint-fix, deps …The agent-service 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.
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:
| Project | Override |
|---|---|
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. |
sensor-simulator | Has no unit suite wired into CI, so it excludes the inherited test task. |
tooling, eslint-config, tests | Tagged language: 'javascript' so they inherit only deps. (backend/tests holds an integration-only Jest suite that runs via just test integration, never as a moon :test.) |
claims-gateway, tenancy-service, registry-service, provisioner | Tagged language: 'go'; each moon.yml is a single line, same as a standard TypeScript or Rust service. Unlike the other four languages, Go itself isn’t pinned in \.mise.toml — install it yourself (see Setting Up the Environment — and scripts/install.mjs doesn’t fetch Go modules during just setup install (they resolve lazily on first build/test). |
These read-only commands print resolved configuration without running anything:
mise exec -- moon task twin-service: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 taskThe payoff of the design is that each fact lives in exactly one place.
| Question | Answered 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 |
Crucially, install and clean derive their package list from moon rather than keeping a parallel hand-written list. scripts/lib/workspaces.mjs shells out to moon query projects and buckets the result by language:
const { projects } = JSON.parse(execSync(withMise('moon query projects')));So just setup install installs exactly the packages moon knows about; no drift is possible.
install and clean are scripts, not moon tasks, on purpose: npm ci wipes and reinstalls node_modules, which is not a cacheable artifact, and making moon manage installs would require enabling moon’s own toolchain — downloading a second Node and conflicting with mise as the single source of truth. So moon owns build/test/lint/audit/deps; the scripts own install/clean; mise owns versions.
Thanks to the single registry, onboarding a package is a one-place change:
\.moon/workspace.yml under projects:.moon.yml in the package with its language: (and any genuine task overrides).:build / :test / :lint / :deps, and :audit for every language except go), and — for typescript, python, and rust — into just setup install / just setup clean-install (both derive from moon query projects, but scripts/install.mjs doesn’t have a go bucket, so a new Go package needs its modules fetched manually the first time; see Setting Up the Environment.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).
| Symptom | Cause and fix |
|---|---|
'uv' is not recognized / command not found | A 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 everything | Expected 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 installed | It 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. |