Skip to content

Upgrade

The premise of the folder contract is that mx owns the verb layer, so improvements to make dev can reach a project scaffolded a year ago. mx upgrade is the command that carries them.

Terminal window
mx upgrade --dry-run # show what would be done, change nothing
mx upgrade --diff # show a diff for each changed file
mx upgrade # walk the changes interactively
mx upgrade --yes # accept everything, non-interactive

Discovery reads the layout that actually ships: templates/Makefile.template, templates/make/*.mk, the top-level files of templates/scripts/ and templates/docker/. A project scaffolded before the compose isolation change below sees exactly the ten script files that changed:

$ mx upgrade --dry-run
🦝 MechCrate Project Upgrade
β†’ Upgrading project at: /path/to/legacyapp
Checking directories...
Scanning templates...
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ πŸ“ Tooling Updates Available β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
The following tooling files have updates available:
β€’ scripts/.bashrc
β€’ scripts/doctor.sh
β€’ scripts/down.sh
β€’ scripts/exec.sh
β€’ scripts/logs.sh
β€’ scripts/ps.sh
β€’ scripts/run.sh
β€’ scripts/sh.sh
β€’ scripts/stop.sh
β€’ scripts/test.sh
[DRY RUN] Would prompt to update 10 file(s)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ πŸ“Š Upgrade Summary β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
[DRY RUN] No changes were made
🦝 Crate Raccoon says: Your tooling is fresh!

Exit code 0. mx upgrade --yes then applies that plan and keeps a .bak beside every file it replaces, and the run after it reports βœ“ Project is up to date!. The command is safe to repeat: it compares content, so a project already current is a no-op rather than a second round of prompts.

Which files an upgrade may touch follows the same rule that governs who owns what in the folder contract. Every template file is categorised:

CategoryPathsBehaviour
ToolingMakefile, make/*.mk, every top-level file of scripts/ (including the extensionless scripts/.bashrc)Offered for update (these are mx’s)
Configdocker/compose/, docker/config/, docker/dockerfiles/, docker/system/Added if missing, never overwritten (these are yours)
Conditionalmake/cloudflare.mk, scripts/cf-*.sh, infra/cloudflare/Only touched if the project has infra/cloudflare/
Skiprecipes/, router/, nested script bundles such as scripts/md2pdf/Not part of a project upgrade

The line that matters: your compose files and dockerfiles are never replaced. An upgrade can improve how make dev composes them; it cannot rewrite what they say. That is what makes accepting an upgrade a low-stakes decision rather than a merge conflict.

Conditional files are keyed on evidence rather than a config flag. The Cloudflare set is in scope exactly when infra/cloudflare/ exists on disk, so a project that never opted in never sees those files offered.

Skip is a scope rule, not an oversight. mx new copies the top-level files of templates/scripts/ and nothing deeper, so an upgrade that laid down scripts/md2pdf/ would be handing an existing project a subtree a fresh project never gets. Upgrade’s reach and mx new’s reach are the same set, and a test holds them there.

One upgrade in this set changes behaviour you can feel, so it is worth reading before you accept it.

Every mx project now pins COMPOSE_PROJECT_NAME to its sanitised project-directory name. scripts/.bashrc is the single source for that value, and every script that shells out to compose passes -p "$COMPOSE_PROJECT_NAME". Two mx projects on one machine therefore no longer share a namespace, and cannot adopt or recreate each other’s containers.

The migration cost is that a project’s namespace changes. Containers started before the upgrade ran under the default name compose derives from the compose files’ parent directory, which is the string compose for every mx project on the machine. After upgrading, make down and make ps look in the new namespace and no longer see them. They are orphans, still running, invisible to the project’s own verbs.

make doctor is where you find them:

β„Ή Checking compose project name...
βœ“ Compose project name: legacyapp
βœ“ No orphans left under the old 'compose' project name
β„Ή Another stack is using the shared default project name 'compose':
- other-db (service 'db', from /path/to/another-project/docker/compose)
β„Ή Left alone - pinning 'legacyapp' is what keeps this project out of it.

When there is something of yours to clean up, the same check names each container and tells you to remove them once with docker rm -f <name>, then run make dev again. Ownership is settled by compose’s own project.working_dir label, which is how a colleague’s stack sitting in the shared namespace gets reported and left alone rather than offered up for deletion.

Two limits are worth knowing. A docker compose -f docker/compose/api.yml ... typed by hand, outside make and the scripts, still gets the default name, so pass -p yourself if you work that way. And two project directories that happen to share a directory name still derive the same project name, so either name them distinctly or export COMPOSE_PROJECT_NAME in your own environment, which wins over the default.

A second behavioural change rides in the same set, and it is the one most likely to break something you wrote.

The shipped compose files used to pin container_name: db, container_name: api and so on. With project names pinned per project, that became a hard blocker rather than a cosmetic choice: container names are a Docker-daemon-wide namespace, so two projects whose compose files both declared db could not run at the same time. The second one failed with Conflict. The container name "/db" is already in use. Those pins are gone from every template and every recipe, and a conformance test keeps them gone.

Compose now derives the name itself:

<COMPOSE_PROJECT_NAME>-<service>-<index> e.g. myproj-db-1

Service names did not change. depends_on, the include: graph, the Traefik labels and every s=<service> on the make line address services, so all of that behaves exactly as before. Upgrading does not change a single compose file of yours either, because compose files are yours and an upgrade never rewrites them. What changes is what you get after you adopt a newer template or apply a newer recipe, and what breaks meanwhile is anything that addressed a container by a fixed name:

Terminal window
# before
docker exec db psql -U postgres
docker logs -f api
# after
make exec s=db c=psql
make logs s=api
docker compose -p "$COMPOSE_PROJECT_NAME" exec db psql -U postgres
docker compose -p "$COMPOSE_PROJECT_NAME" logs -f api

The project’s own verbs are the stable interface, and they were always the intended one. Reach for the explicit docker compose -p form when you need something the verbs do not cover.

One exception: the global router container is still mx-router. It is installed once per machine rather than per project, so it has nothing to collide with, and mx router addresses it by that name.

Every dev override used to publish a fixed host port, which is why a second stack died on Bind for 0.0.0.0:5432 failed: port is already allocated with nothing in the project to edit short of hand-patching a shipped template. The repair asked one question per port: who dials it?

Ports that tooling dials on demand now publish on host port 0, which Docker allocates from the free range, so they can never collide:

Container portWasPin it with
Postgres 54325432:5432DB_HOST_PORT
Redis 63796379:6379REDIS_HOST_PORT
Node inspector 92299229:9229NODE_DEBUG_HOST_PORT
Worker metrics 90909090:9090METRICS_HOST_PORT
Vite 5173 (laravel)5173:5173VITE_HOST_PORT
Inertia SSR 1371413714:13714INERTIA_SSR_HOST_PORT

Discover the allocated one instead of guessing:

Terminal window
docker compose -p "$COMPOSE_PROJECT_NAME" port db 5432

Or pin one for a session, leaving its siblings ephemeral:

Terminal window
DB_HOST_PORT=5432 make dev

Ports a browser dials from a URL it already holds keep today’s number as a default and gain an override, so an overriding second stack moves instead of failing to boot: LEPTOS_RELOAD_HOST_PORT (3001), ZOLA_LIVERELOAD_PORT (1024), NGINX_HTTP_PORT / NGINX_HTTPS_PORT, TRAEFIK_HTTP_PORT / TRAEFIK_HTTPS_PORT / TRAEFIK_DASHBOARD_PORT, and the global router’s own MX_ROUTER_HTTP_PORT / MX_ROUTER_HTTPS_PORT.

What breaks is a script or GUI connection profile pointed at localhost:5432, localhost:6379, localhost:9229, localhost:9090, localhost:5173, localhost:13714 or localhost:24678 of an mx dev stack. Discover the port or pin it.

traefik.http.routers.<service>.* is now traefik.http.routers.${COMPOSE_PROJECT_NAME}-<service>.*, and the same for .services. and .middlewares..

Traefik keeps one router table, one service table and one middleware table per provider for the whole machine, so the old unqualified names meant two projects that each scaffolded an api service wrote the same key. Mismatched labels made Traefik drop the router outright and 404 both hostnames; matching labels were quieter and worse, merging both containers into one load-balancer pool so consecutive requests alternated between two projects’ apps with nothing logged.

Compose resolves COMPOSE_PROJECT_NAME from the project name it already resolved, so nothing has to be exported and the qualification holds under a hand-rolled docker compose -f …. Hostnames are unchanged, and the routing rule gained a per-service override in the default position:

- traefik.http.routers.${COMPOSE_PROJECT_NAME}-api.rule=Host(`${API_ROUTER_HOST:-api.localhost}`)

What breaks is a script or dashboard bookmark that addressed a router or service entry by its bare service name. api@docker is now myproj-api@docker. The global mx-router container is unaffected: it configures Traefik by file rather than by label, and one machine has one router.

mx upgrade covers the project skeleton. Service-level scaffolding comes from recipes and has its own path: mx recipes apply <name> re-applies a recipe to the current project, and mx recipes versions <name> lists what is available. See Recipes.