Skip to content

Board the newest train

$ grim update --dry-run

A project does not update module by module. Core, every module and every theme are released together on one version line, a train, named by its MAJOR.MINOR. grim update moves all of them onto the newest train that the core constraint in grim.json allows, then writes grim.lock again, whole.

Given the name of a module or a theme it does the smaller thing: that one unit, to its newest release on the train the project is already locked to. It will not cross into a newer train for one unit, because a module from one train against the core of another is exactly the combination trains exist to rule out.

Usage

$ grim update --dry-run     # the plan, nothing changed
$ grim update               # the whole project, onto the newest train
$ grim update cart          # one module, inside the locked train
$ grim update floatv3       # one theme, the same way

Run it from the project directory, logged in with grim auth:login, in a project that already has a grim.lock. Run the dry run first, every time. It is the only place you see which files are about to be removed from the project before they are.

Arguments and options

Move the project onto the newest release train matching grim.json

Usage

grim update [--dry-run] [-f|--force] [--allow-dirty] [--no-docker] [--] [<unit>]

Arguments

Argument Description
unit Update a single module or theme, inside the locked train (optional)

Options

Option Description
--dry-run Show the plan without applying it
-f, --force Reinstall units already at their target version
--allow-dirty Run over uncommitted changes to tracked files (instances that track their whole tree)
--no-docker Leave the containers alone: no composer install or migrate after a train move

What it actually does

In this order, and every step is printed as it happens.

  1. Checks every package can be had. An archived module, a private one you have no access to, or one that does not exist stops the whole update before anything is fetched, and says which.
  2. Picks the train. Resolves core from grim.json to a grim-core release and takes its MAJOR.MINOR. That is the train for everything else.
  3. Prints the plan. A headline such as Train 2.4 → 2.5 available (core 2.5.0), then one row per unit that moves: current version, target, and the constraint that allowed it. With nothing to move it prints Everything is up to date on train 2.5. and stops.
  4. Lands core. The new core release is merged into the working tree against the installed one, as described in When the instance tracks its whole tree. Your grim.json, .env, storage/, and the root README.md and CLAUDE.md are never overwritten by core's copies; of .gitignore only grim's block between # >>> grim and # <<< grim is rewritten. Before 1.0.50 core brought its own README and the monorepo's agent guide into every project; an update now replaces those two copies with short files about this project (Wrote the instance's own README.md and CLAUDE.md) and leaves a README or CLAUDE.md you wrote yourself alone. One exception: CLAUDE.md has to import @AGENTS.md, which loads grim-core's agent map into Claude Code. A CLAUDE.md of yours without the import gets it as its first line and keeps the rest; one grim wrote earlier and nobody edited is replaced by the current one. The same goes for a README grim wrote before model A and nobody edited: it is replaced by the current one. Not in --dry-run.
  5. Removes what core stopped shipping. Files the previous grim-core release carried and the new one does not are deleted, and each is named. See what gets removed.
  6. Lands modules and themes. Each moves to its new release, merged with your edits as described in When the instance tracks its whole tree.
  7. Writes grim.lock. The train, the core version and commit, and every module and theme with its version and commit. config/modules-available.php is regenerated from it, and so are the Claude Code skill shims .claude/skills/module-<slug>/SKILL.md — one per src/<Module>/AGENTS.md, written by core's .claude/sync-module-skills.php (gitignored; an update with nothing to move refreshes them too).
  8. Catches the container up. When core moved: composer install and php artisan migrate --force in the running app container. If the environment is down, or Docker is missing or not running, it says so and leaves that to the next grim up. --no-docker skips this step, for a machine without Docker such as a CI job that runs composer and the migrations itself.
  9. Offers to prune. Modules left on disk that nothing in grim.json needs any more are listed, with a prompt to remove them now.
  10. Refreshes the JavaScript lock. When the update moved a package.json or pnpm-workspace.yaml, or the project has no pnpm-lock.yaml, pnpm install runs on your machine. See JavaScript dependencies.

A single-unit update skips steps 4, 5 and 8, not step 10: a module or theme may carry a package.json of its own. It never touches core, and the rest of the lock is carried over as it was. A theme is found by its name in either slot, so grim update floatv3 moves the admin theme and nothing else.

Either way the lock lists only the themes grim.json still declares. A theme you took out of a slot leaves the lock on the next update; the directory stays on disk until grim prune removes it.

JavaScript dependencies

pnpm-lock.yaml belongs to the project, not to core. Core brings the root package.json and pnpm-workspace.yaml, a module or theme may bring a package.json of its own, and the lock that pins them together is resolved in the project and committed there. Step 10 runs pnpm install without --frozen-lockfile and ends with ✓ pnpm install — commit pnpm-lock.yaml with the update when the lock changed, or ✓ pnpm install — pnpm-lock.yaml unchanged, nothing to commit when it came out the same.

It runs only when the update moved one of those manifests or the lock is missing. With a conflict in one of them it stops at … in conflict. Resolve the conflicts, then run `pnpm install` and commit pnpm-lock.yaml., and without pnpm on your machine it only asks you to run it. --dry-run runs nothing and says Would run `pnpm install`: the update moves a package.json or pnpm-workspace.yaml …, or, when there is no lock yet, Would run `pnpm install` to write pnpm-lock.yaml, which the instance commits.

A project whose core still carried the lock (grim-core before 1.3.43) sees it handed over on the first update past it, with the target version in the line: Kept pnpm-lock.yaml — grim-core 1.3.44 no longer carries it; it belongs to the instance now. Nothing is deleted. The production image installs from that lock with --frozen-lockfile, so building a workspace without it stops at pnpm-lock.yaml is missing — run `pnpm install` and commit pnpm-lock.yaml.

What gets removed

A file that left core upstream would otherwise stay in every project for good: a deleted test that keeps failing, a deleted class that keeps autoloading. Update removes the difference between the old core release and the new one, with these exceptions.

Case What happens
Unchanged since the old release Removed, and named: Removed <path>. The dry run prints Would remove <path>.
Edited locally Kept, with a warning. An edited copy of a file upstream dropped is more likely deliberate than drift.
Instance state grim.json, grim.lock, .env and storage/ are never touched.
Files grim manages The infrastructure files grim upgrade writes, and config/modules-available.php, are left alone.
A module that left core Nothing is deleted. You are told to add it to grim.json or run grim remove.

When the old core tree cannot be read, for example because the lock records no release version of core, the train still lands and update prints that nothing was removed. It does not guess.

When the instance tracks its whole tree

An instance whose .gitignore is a denylist and whose modules and themes carry no .git of their own tracks core and every unit as plain files. Update then lands the new releases as a diff in the working tree and never commits it.

  • It needs a git repository and no uncommitted changes to tracked files. --allow-dirty runs it anyway.
  • The installed release in grim.lock is the base. A file you have not edited takes the new version, or is removed when the new release dropped it. A file you edited is merged three ways with git merge-file.
  • A clash leaves the usual conflict markers. A file you edited that upstream dropped stays (modify/delete). A file you deleted that upstream changed stays deleted (delete/modify). A new upstream file you already have with other contents is add/add. A binary or a symlink changed on both sides keeps your copy as a conflict. Every conflict is listed, grim.lock is still written, and update exits non-zero, like git merge.
  • The exceptions in the table above still hold, and project code outside core's tree and the unit directories is never read.
  • It ends with a commit message to use: chore(grim): core 1.0.62 → 1.0.63, cart 1.0.62 → 1.0.63.

Release trees are kept in .grim/upstream/, so a tree fetched once is not fetched again. An instance from before this model — an allowlist .gitignore, units with a .git of their own — is refused; run grim adopt first.

Pitfalls

~2.0 is not a pinned train

Composer reads a two-part tilde as >=2.0 <3.0.0, so "core": "~2.0" follows every train of major 2. To stay on one train, write "core": "2.4.*". Either way, no module ever drifts off the train core landed on.

Update does not touch Docker files

docker-compose.yml, docker/ and the Dockerfiles come from the templates of the grim you have installed, not from core. After grim self-update, run grim upgrade to bring them in line. An update that finds a Dockerfile building FROM an image in ghcr.io that is not one of grim's base images says so at the end — grim install --regenerate-docker rewrites them and keeps the old ones as *.bak.

A conflict stops the update half-way

Every conflicted file is listed, grim.lock already names the new versions, and update exits non-zero. Resolve the markers as after a git merge, run the suite, and commit the whole result with the lock. git checkout . undoes the update instead, since the tree was clean when it started; files the new release added stay behind as untracked, and git status lists them.

A single unit refuses to move

Train 2.5 available; run `grim update` (train) means a newer train exists and the project is not on it. Update the whole project first; single-unit updates only work inside the train in grim.lock.

The name is neither a module nor a theme

"x" is neither a module under "requires" nor a theme in a slot of grim.json. — the argument is looked up in requires first, then in the frontend and admin slots. Check the spelling against grim.json; a package the project does not declare is added with grim require, not updated.

A unit has no release on the new train

⚠ module cart has no release on train 2.5 (~2.0) — left as it is. in the plan means everything else moves and that unit stays on its old version. Wait for its release on the train, or loosen its constraint in grim.json if the constraint is what rules it out.

--allow-dirty runs the merge over uncommitted changes, and then nothing separates your edits from the update in git diff. Commit first unless you know why not.

After it finishes

Run the suite, then look at what changed before committing the new lock, grim.lock and, when update said so, pnpm-lock.yaml.

$ grim test
$ git diff --stat

The whole move, with the review and the restart around it, is the recipe Move a project to a new train.

Recipes that use it

The idea underneath