mx Recipes & Build Pipeline: Lifecycle, Distribution, and Images
Companion to mx-app-playbook.md. That doc covers using mx; this one covers the machinery underneath: where recipes come from, how they reach machines and projects, what happens (and doesn’t) when they change, and exactly how images get built. All bracketed citations are repo-relative paths in the mech-crate repo.
1. Two different things are called “recipe” — never conflate them
| Local scaffolding recipes | Unyform remote recipes (“blueprints”) | |
|---|---|---|
| Live at | templates/recipes/<name>/ (JSON + template tree) | api.unyform.ai, generated server-side from connected repos |
| Consumed by | mx add, mx new --with, mx recipes list/info | mx recipes pull/apply/versions/cache, mx unyform, MCP unyform_* |
| Produce | Real files: apps/, compose pair, dockerfiles, env | One coding-rules doc: .cursor/rules/<name>-patterns.md |
| Versioning | version field exists but is dead metadata — never read, shown, or compared [crates/mx-lib/src/recipe/parser.rs:26-28] | Real versions: list/pull name@version, versions endpoint, cached per-version |
mx recipes straddles both: list/info are local, pull/apply/versions/cache are Unyform [crates/mx-cli/src/commands/recipes.rs:57-69].
2. Local recipe lifecycle
Authoring. A recipe is recipe.json (services, options, placeholders with slug|upper|rust_crate|ssr_bool transforms, init_app scaffolder, templates mappings, post_install, next_steps) plus its template files [templates/recipes/README.md; crates/mx-lib/src/recipe/parser.rs]. Shared fragments are referenced cross-recipe via common:// → templates/recipes/common/ [installer.rs:202-214]. Full authoring guide: RECIPE_AUTHORING_GUIDE.md.
Discovery & resolution. mx recipes list/info and mx add resolve recipes ONLY from <templates>/recipes, where <templates> is searched in order: $MECH_CRATE_ROOT/templates → ~/.mech-crate/templates → exe-relative walk-up [crates/mx-lib/src/paths.rs:65-102]. Two traps:
~/.mech-crate/recipesis not a recipe source — it’s the Unyform download cache [crates/mx-lib/src/config.rs:39-42].- Projects cannot carry their own recipes — resolution never consults the project dir or cwd.
Consumption. mx add <name> --recipe=<r> runs the installer: placeholders → mkdir → init_app (skipped if target exists + skip_if_exists) → Tera-interpolated template copy → post_install → next_steps [installer.rs:71-123]. File mapping (rust-api, representative): service.yml→docker/compose/<svc>.yml, service.dev.yml→<svc>.dev.yml, app.Dockerfile→docker/dockerfiles/<svc>/app, app.prod.Dockerfile→…/app.prod, app/→apps/<svc>/ [templates/recipes/rust-api/recipe.json:61-80].
Maintenance & testing. Recipes are hand-edited in templates/recipes/. Validation today: serde parse at load + parser/interpolator unit tests against hardcoded JSON only [parser.rs:359-420]; shell smoke tests exist in tests/testbed/ (laravel, rust-api scaffold) but are not wired into CI [.github/workflows/release.yml is the only workflow]. No linter loads the real on-disk recipe.json files.
Distribution to machines. Recipe edits reach a machine only via a full templates recopy:
mx init --update(or--force): deletes and recopies~/.mech-crate/templatesfrom the source repo [crates/mx-cli/src/commands/init.rs:92-121].mx self-update [--pull]: git pull, rebuild binaries, then runsmx init --force— so it refreshes recipes as a side effect [crates/mx-cli/src/commands/self_update.rs:96-249].- There is no unyform push — the remote registry is read-only from this CLI; blueprints are generated server-side from connected repositories [crates/mx-mcp-server/src/tools/mod.rs:2275-2278].
3. Unyform blueprint distribution (the built mechanism)
Auth. mx login --api-key <k> → GET /v1/auth/me, credentials stored at ~/.mech-crate/config/unyform/credentials.json (0600) [crates/mx-lib/src/unyform/mod.rs:119-157, 241-270]. Default base https://api.unyform.ai. Browser OAuth is not implemented in the CLI [crates/mx-cli/src/commands/unyform.rs:73-107].
Commands and endpoints [mx-lib/unyform/mod.rs:296-452; recipes.rs:178-338]:
| Command | Endpoint | Effect |
|---|---|---|
mx recipes list | GET /v1/orgs/{org}/recipes | Remote list appended after local list when logged in |
mx recipes versions <name> | GET …/recipes/{name}/versions | Prints version, is_latest, generated_at |
mx recipes pull <name[@ver]> | GET …/recipes/{name}[/versions/{v}] | Caches to ~/.mech-crate/recipes/{org}/{name}/{version}/recipe.json + latest symlink |
mx recipes apply <name[@ver]> | same GET (always API, ignores cache) | Writes .cursor/rules/<name>-patterns.md from patterns |
mx recipes cache [list|clear] | — | Walks/clears the cache tree |
Cache layout: ~/.mech-crate/recipes/{org}/{name}/{version}/recipe.json; latest symlink points at the last pulled version (not necessarily newest). The MCP client also writes a manifest.json (pulled_at/org/name/version) — the only pull provenance that exists anywhere.
Known defects in this surface (verified; candidates for issues):
apply --fixis a dead flag (_fixunused) and the advertised dependency-drift/infrastructure comparison is unimplemented [recipes.rs:206; tools/mod.rs:1325-1333, 2358].- The two Unyform clients disagree on the org path segment: CLI sends the org id, MCP sends the org slug [mx-lib/unyform/mod.rs:206-211 vs mx-mcp-server/unyform/mod.rs:258-267].
- No update checking or pinning: nothing compares cached vs remote versions.
mx initrecords~/.mech-crate/config/source-rootbutmx self-update’s fallback reads~/.mech-crate/source— mismatched marker files [paths.rs:17-21 vs self_update.rs:315].
4. Consumer update propagation — what exists vs the standard approach
What exists today (the honest answer): recipe-generated files have no update path.
mx upgraderefreshes tooling (make/, scripts/, Makefile — prompt-to-update with.bakbackups) and treats docker config as add-if-missing; anything underrecipes/is categorized Skip — recipe-generated services are never touched [crates/mx-lib/src/upgrade/mod.rs:66-102]. (And upgrade is currently broken anyway — seemx-app-playbook.md§4.)- Re-running
mx addoverwrites template-managed files unconditionally — an “update” that also clobbers local edits, with no diff, no merge, no prompt [installer.rs:252-299]. - Unyform
applyrewrites its rules file wholesale each run; fine for that file, irrelevant for scaffolded code. - No project records which recipe or version generated its services. No lockfile, no marker;
InstallResultis printed and discarded [installer.rs:401-409]. Upstream changes therefore cannot even be detected from a consumer project.
Synthesis (inferred)
The standard approach until mx grows native support — treat recipes as scaffolding, not a managed dependency, and make provenance explicit:
- Record provenance at add-time. Immediately after
mx add, commit adocker/.config/recipe-lock.jsonin the project:{service: {recipe, version (from recipe.json), templates_sha (git SHA of mech-crate at add time), added_at}}. This is convention, not tooling — but it makes every later question answerable. - Updating a service against a newer recipe = regenerate-and-diff, never overwrite-in-place: run
mx add <svc> --recipe=<r>in a scratch project, then diff the rendered output against the real project’s files and port changes deliberately. Hand-edited compose/dockerfiles are the norm, so a blind re-add is a destructive operation — the scratch-render diff is the safe substitute for the missing update command. - Divergence is expected and fine for app source (
apps/); keep the contract surfaces (compose labels/networks, env layering, dockerfile stage names) recipe-conformant so tooling and the router keep working. - Unyform blueprints: pull pinned (
name@version), commit the applied.cursor/rulesfile, and re-apply deliberately after reviewingmx recipes versions <name>— thelatestsymlink is last-pulled, so never trust it as “newest”. - The native fix (proposed as an mx feature, filed as an issue):
mx addwrites the lock entry itself, and anmx recipe diff <svc>renders the current recipe to a temp dir and shows the three-way delta. That single pair would turn recipes into a real managed dependency.
5. The build pipeline — three paths, three version semantics
| Aspect | make build / mx build | Compose (make dev/up) | Cloudflare (make cf-*) |
|---|---|---|---|
| Dockerfile (prod) | prefers docker/dockerfiles/<svc>/app.prod, falls back to app | always app, target: production | app |
| Builder | DOCKER_BUILDKIT=1 docker build | docker compose up — builds only if image absent (no --build anywhere) | docker buildx build --platform linux/amd64 --load |
| Version into image | IMAGE_VERSION = git branch:sha8[-dirty]; tag from t= (default latest) | none | APP_VERSION = package.json version |
| Image name | <PROJECT>/<svc>:<tag> + :<tag>-dev|-prod twin | compose default naming or <SVC>_IMAGE_TAG from docker/compose/.env | registry.cloudflare.com/<acct>/<app>:v<ver> |
| Push | docker push iff push=1 (no registry prefix logic) | n/a | npx wrangler containers push |
[templates/make/build.mk; templates/scripts/build.sh:29-202; templates/scripts/.bashrc:36-180; templates/make/cloudflare.mk:99-186]
make build s=<svc> [t=<tag>] [prod=1] [push=1] [nocache=1] → scripts/build.sh: picks the dockerfile by mode (prod prefers the hardened distroless app.prod; dev uses the app file’s development stage), builds from project root context, passes IMAGE_VERSION/IMAGE_TAG/BUILD_TIME/BUILD_MODE build-args (+NODE_ENV/RUST_ENV/APP_ENV=production in prod), runs an optional scripts/prebuild/<svc>.sh hook, and writes the tag back to docker/compose/.env as <SVC>_IMAGE_TAG [build.sh:55-185]. mx build is a thin wrapper over make _build (MCP mx_build → mx build → make → build.sh) [crates/mx-cli/src/commands/build.rs:48-102; crates/mx-mcp-server/src/mx/mod.rs:215-239].
Compose builds: recipe compose declares build: {context: ../.., dockerfile: docker/dockerfiles/<svc>/app, target: production} with the dev override flipping only target: development [templates/recipes/rust-api/docker/compose/service.yml:9-14, service.dev.yml:6-8]. Because up never passes --build, compose reuses existing images; to pick up code changes in the image you must make build (or rely on dev bind-mounts, which is why dev mode rarely needs rebuilds). There is no make rebuild — it’s listed .PHONY but never defined [templates/make/common.mk:4].
Dockerfile convention (rust-api, representative): one multi-stage app file — chef → planner → builder (cargo-chef cached deps) then a development stage (cargo watch) and a production stage (alpine, non-root) [templates/recipes/rust-api/docker/dockerfiles/app.Dockerfile]; plus a hardened app.prod (static musl, stripped, distroless nonroot) used only by the standalone prod build [app.prod.Dockerfile].
Release flow: make release* app=<app> targets [templates/make/release.mk] — version source of truth is apps/<app>/package.json; conventional-commit releases delegate to per-app yarn release* (standard-version), post_release syncs versions into .release-please-manifest.json and amends the changelog; release auto-pushes with --follow-tags. The simple path (release-simple*) uses npm version + tag <app>-v<ver> without conventional commits [simple-release.sh]. Gap: recipe-generated apps don’t ship the release* package scripts or standard-version — make release fails on a fresh recipe app until they’re added (bootstrap steps in appendix-build-deploy-recipe.md); release-simple works out of the box.
Released version ≠ image version for standalone/compose builds (git-derived IMAGE_VERSION only); the Cloudflare path is the only one that stamps APP_VERSION from package.json into the image [cloudflare.mk:24-29, 108].
6. Gaps inventory (as of research date)
Filed or worth filing as mx issues; treat these as “known, don’t rediscover”:
- Local recipe
versionis dead metadata; no changelog/version discipline for scaffolding recipes. - No consumer provenance (no lockfile) → no update detection or propagation for recipe-generated code (§4 standard approach compensates).
mx recipes apply --fixdead flag; advertised dependency/infrastructure comparison unimplemented.- Unyform org id-vs-slug mismatch between CLI and MCP clients.
mx upgradebroken (templates/project/missing — also flagged in mx-app-playbook).mx build --platformis accepted but dropped by the make layer (_builddoesn’t forward it); onlybuild-multiplatformwires platforms through.- No
make rebuild; compose never passes--build. - Recipe apps lack
release*scripts →make releasefails until bootstrapped. source-rootvssourcemarker mismatch (init vs self-update).- testbed smoke tests not in CI; no recipe.json validator.
7. Agent quick-reference
- “Recipe” alone = local scaffolding; “blueprint”/pull/apply/versions = Unyform remote. Different systems, different outputs.
- Updating a recipe-built service: scratch-render + diff (§4.2). Never re-run
mx addover hand-edited files. - After editing recipes in the mx repo:
mx init --updateto distribute to the machine. - Fresh code into a prod image:
make build s=<svc> prod=1(compose won’t rebuild for you). - Pin Unyform pulls with
@version; don’t trust thelatestsymlink.