The installer/manager for an Atelier CMS appliance. One shared Rust engine, two front-ends:
atelierCLI (crates/aincient-cli) — the developer/power-user front door, distributed via Homebrew and cross-platform installers.- Tauri GUI (
app/) — a double-click manager for non-technical users. Plain HTML/CSS/JS frontend (no bundler) over the same core, via Tauri commands.
Both link aincient-core (crates/aincient-core) directly, so there is exactly one
implementation of every operation and no duplicated logic.
Install friction is the biggest leak in the evaluator funnel. The real prerequisite floor is Docker (a CMS needs PHP + a database + storage), not the terminal — so the manager is a lifecycle manager over the existing appliance, not a way to skip prerequisites. Every operation maps to a lower-level primitive the appliance already provides:
Commands are grouped into noun namespaces so the surface stays maintainable as it grows
(doctor is the one flat, universal command):
| Command | What it does |
|---|---|
app install |
Lay down ~/.atelier/{compose.yaml,.env}, pull, up -d. Idempotent. |
app update |
pull + up -d — converge.sh migrates in place and auto-rolls-back. Steps through intermediate versions when one hop can't get there (--to X.Y.Z, -y). |
app check-update |
Compare the local image digest against the registry tag, and show the route the update would take. (alias app check) |
app channel [stable|edge] |
Show or switch which images the install follows. --now pulls immediately. |
app reinstall |
Wipe volumes and install fresh (destructive, confirmed). |
app status |
Read-only health probe. (The full checkup is the flat doctor.) |
app start/stop/down/logs/open/password |
Everyday stack management. |
site export |
Export the public site to static HTML — the deploy-anywhere artifact. |
site freeze [--label] |
Freeze the published site into a snapshot inside the appliance and serve it to visitors; logged-in users keep seeing live. |
site snapshots |
List snapshots (--json for machines); ● marks what visitors see. |
site use <id>/live |
Serve a snapshot, or go back to live. Instant; nothing restarts. |
site prune [--keep N] |
Delete old snapshots; kept and served ones stay. |
data backup |
Portable .tar.gz snapshot (DB dump + uploaded files + manifest) → ~/.atelier/backups. (alias data export) |
data restore <file> |
Restore a .tar.gz snapshot (DB + files, re-chowned) or a legacy .sql/.sql.gz dump (DB only). (alias data import) |
data list |
List snapshots on this host. (alias data backups) |
ai model list/set |
Inspect or bind the AI model per Atelier role. |
doctor |
Diagnose host + stack + site. --fix repairs safely, --json for bug reports. |
export/import alias data backup/restore so either mental model works; export is
never a bare top-level verb (it means one of three things — static site, db+files, db-only —
so it's always qualified by its namespace). The stack directory defaults to ~/.atelier
(override with ATELIER_HOME) and holds the same compose.yaml + .env the
docker/install.sh bootstrapper writes.
AINCIENT_IMAGE decides everything Docker does, so a "channel" is just a policy about which tag
it names: stable = :latest (retagged on every release, the default), edge = :edge
(rebuilt on every merge to main), and anything else — a :vX.Y.Z tag, a digest, a local build —
is pinned and never moves, so no update can arrive through it.
.env also carries AINCIENT_CHANNEL, which records the channel the operator chose rather than
the one they're on. It exists for exactly one reason: installs made before there were releases
have :edge baked into their .env because edge was all there was, and they need to be told apart
from an operator who asked for unreleased builds. install/update move the former onto stable
once (snapshotting first — edge can be ahead of the newest release, and Drupal only migrates
forward), announce it, and write the marker so it can never fire twice.
A release can be unable to migrate arbitrarily old state. The concrete case: the update
that uninstalls a module must run while that module is still in the image, so the release
that finally drops it from composer.json can only migrate sites that already ran it.
Every appliance image therefore declares the oldest version it can migrate from, as the
OCI label dev.atelier.upgrade.min-from (see docker/upgrade-floor in the cms repo).
app update reads those labels straight from the registry, pulling nothing, walks them
backwards from the target until it reaches a version the install satisfies, and shows the
route before doing anything:
$ atelier app update
This upgrade takes more than one step
from 0.1.1
1. 0.3.0 (0.4.0 can't migrate a site this old directly)
2. 0.4.0
Each step pulls its image, migrates, and is health-checked before the next one starts;
a step that fails rolls its own database back. A full snapshot is taken first.
Walk the whole route now? [y/N]:
Each hop must finish converging before the next begins — the floor is checked against the
version the site recorded on its last successful converge, so running ahead of one would
present the next image with two-versions-ago state and earn its refusal. A hop that doesn't
come up stops the route and says so; the site is left on the last version that converged,
which is a state the appliance guarantees (converge rolls its own database back on failure).
app check-update reports the same route, so a long upgrade is visible before it's started.
The manager is the ergonomics, not the safety: the appliance refuses an impossible migration
itself, before touching the database, so a hand-run docker compose pull fails safely with a
message naming the version to go through. That also means an unreadable registry or an
unstamped local image costs the route, not the safety — the plan falls back to one direct
hop and says why it wasn't verified.
--to X.Y.Z stops at a specific version (which leaves the install pinned to it —
app channel stable resumes following releases). -y skips the confirmation.
atelier doctor is read-only and checks three tiers in dependency order, so a failure never
masquerades as the tier below it (a check whose prerequisite failed reports skipped, not
failed):
| Tier | Checks |
|---|---|
| host | Docker installed/running, Compose plugin, buildx (only the update check needs it) — each reported with its version, the CLI and the engine separately since they can differ — plus whether another program has taken the console port, and free disk space. |
| stack | compose.yaml present and parseable, .env still has its HASH_SALT, both containers exist, neither is restart-looping or unhealthy. |
| site | Drupal bootstraps, no pending updatedb, uploaded-files tree still owned by www-data, the image's own healthcheck.sh passes, a model role is bound. |
atelier doctor --fix climbs a safe repair ladder, re-diagnosing after each rung and stopping
the moment nothing is failing — so a site that only needed a cache rebuild never gets a full
self-heal run:
- restore the stack files (a replaced
compose.yamlis moved aside, never deleted) docker compose up -d, then wait for the console to actually servedrush cache:rebuilddrush updatedb -ychown -R www-data:www-dataon the files tree- re-run the appliance's
converge.sh— which snapshots the database first and rolls back if the repair makes things worse
Every rung's worst case is lost time. down -v, reinstall and restore are excluded at any
flag — doctor names them as the next step and stops. Exit code is non-zero while anything is
still failing (advisories, like "no model connected yet" on a fresh install, don't count), so it
works as a gate in a script. --json emits the whole report plus the verdict and manager version,
for pasting into an issue.
v0.2.0 renamed the flat commands into these namespaces (BC break vs v0.1.0):
install→app install,backup→data backup,model set→ai model set, and so on.doctoris unchanged.
On launch, the GUI asks GitHub (api.github.com) whether a newer Manager release exists, so it
can show a quiet "get the update" link when this app itself is stale. Nothing about you or your
install is sent — no ID, no version, no OS details, just the request itself — and we receive no
data: GitHub doesn't share API request logs with repository owners, so this can't even count
installs. Offline or rate-limited, the check silently does nothing. The Manager never updates
itself; the appliance update banner is about your site, this link is about the app. (The
appliance's own check-update talks to the image registry the same way — a version lookup,
nothing more.)
cargo build # build the workspace
cargo test # run unit tests
cargo clippy # lint
cargo run -p atelier -- doctor # run the CLI
cargo run -p atelier-manager # launch the GUIThe built CLI binary is target/debug/atelier (or target/release/atelier); the GUI
binary is target/debug/atelier-manager. The GUI is packaged into .dmg/.msi/AppImage
by cargo tauri build (in CI, release-gui.yml); the macOS bundle is Developer ID signed
and notarized when the Apple secrets are configured (see "macOS code signing" below).
One tag ships everything. CLI and GUI share the workspace version and ride the same v*
tag, so they can never drift apart:
git tag v0.2.2 && git push origin v0.2.2That fans out to two workflows on the one tag:
release.yml(cargo-dist, driven bydist-workspace.toml) builds theatelierCLI for every target, creates the GitHub Release, and publishes the Homebrew formula (Formula/atelier.rb) toaincient-labs/homebrew-tapsobrew install aincient-labs/tap/atelierworks.release-gui.yml(tauri-apps/tauri-action) builds the desktop bundles (.dmg/.msi/.AppImage/.deb/.rpm) — which cargo-dist can't produce — and uploads them onto the same release cargo-dist creates for that tag.
Keep app/tauri.conf.json's version in lockstep with the workspace Cargo.toml version when
bumping (the GUI release name is derived from it).
Homebrew-tap prerequisite: the cross-repo formula push needs a HOMEBREW_TAP_TOKEN Actions
secret — a token with Contents: read and write on aincient-labs/homebrew-tap (the built-in
GITHUB_TOKEN can't push to another repo). Note the aincient-labs org rejects fine-grained
PATs whose lifetime exceeds 366 days — if publish-homebrew-formula fails at the tap checkout,
rotate the secret to a ≤366-day token. (Recovery when it fails: the formula is also attached to
the release as atelier.rb, so it can be pushed to the tap by hand.)
The macOS bundle is signed with a Developer ID Application certificate and notarized via the
App Store Connect API. release-gui.yml does this automatically when the secrets below are set;
with them absent the build falls back to an unsigned bundle (Gatekeeper warns on first launch), so the
workflow is safe to run either way. Windows Authenticode is still a fast-follow.
One-time Apple-side setup (all from developer.apple.com):
- Certificate — create a Developer ID Application certificate, then export it from Keychain
Access as a
.p12(with a password). Base64 it for the secret:base64 -i cert.p12 | pbcopy. - Signing identity — the cert's full name, e.g.
Developer ID Application: Your Name (TEAMID)(copy from Keychain Access orsecurity find-identity -v -p codesigning). - Notarization key — App Store Connect → Users and Access → Integrations → App Store Connect API
→ generate a key with the Developer role. Download the
.p8(one-time download), and note the Key ID and Issuer ID. Base64 it for the secret:base64 -i AuthKey_XXXX.p8 | pbcopy.
Then add these repo Actions secrets (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
APPLE_CERTIFICATE |
base64 of the .p12 |
APPLE_CERTIFICATE_PASSWORD |
password you set when exporting the .p12 |
APPLE_SIGNING_IDENTITY |
Developer ID Application: … (TEAMID) |
APPLE_API_ISSUER |
Issuer ID (UUID) from App Store Connect |
APPLE_API_KEY |
Key ID (e.g. XXXXXXXXXX) |
APPLE_API_KEY_BASE64 |
base64 of the .p8 key (workflow decodes it to a file) |
KEYCHAIN_PASSWORD |
(optional) any string; auto-generated if unset |