Self-update design (Phase 29 proposal)
evi update lets a user pull a newer eVi version, verify it works, and roll back if it doesn't. This doc is the design — none of it is built yet. Once approved, it becomes Phase 29.
Goals
- One-command upgrade.
evi updateis the only command a user should need. - Always rollback-able. Every upgrade snapshots the prior
pip freezeso we can restore an exact known-good state. - Refuse to clobber dev work. Editable installs (
pip install -e .) are never touched — we detect and refuse. - Channels later. Stable PyPI is the default. Pre-release + custom index URL come in a follow-up.
- Don't surprise locked envs. Poetry / uv / pipenv projects get a clear "use your tool's upgrade command" message instead of a silently-broken venv.
Non-goals (for now)
- Auto-updating the Tauri desktop binary — Tauri has its own updater; that's a separate phase.
- Updating dependencies independently of eVi (only pinned-via-extras matter for our use cases).
- Background / daemon checks. Update is always user-initiated.
Command surface
| Command | Purpose |
|---|---|
evi update | Check, show diff, prompt, upgrade. |
evi update check | Check only — no install. |
evi update --yes | Non-interactive upgrade. |
evi update --to <version> | Pin an explicit version (downgrade allowed). |
evi update rollback | Restore the most recent snapshot. |
evi update rollback <n> | Restore the n-th most recent (1-indexed). |
evi update rollback <id> | Restore a named snapshot dir. |
evi update history | List snapshots. |
evi update prune --keep <n> | Manual GC of old snapshots. |
evi update from-wheel <path> | Offline install from a local wheel/sdist. |
evi update settings | Print channel + snapshot retention. |
Storage layout
~/.evi/snapshots/
2026-05-27T14-03-00_0.10.0_to_0.11.0/
requirements.txt # pip freeze BEFORE the upgrade
version.txt # "0.10.0"
metadata.json # {"timestamp", "from", "to", "command"}
2026-06-02T09-12-44_0.11.0_to_0.11.1/
…
Snapshots are append-only. Default retention: last 5. A prune runs at the end of each successful upgrade to drop older ones.
State machine
┌──────────────────┐
│ evi update │
│ (or --check) │
└─────────┬────────┘
│
▼
┌──────────────────┐
│ Probe PyPI │ /pypi/evi-assistant/json
└─────────┬────────┘
│
current ≥ latest? ──── yes ──► "up to date"
│
no
│
▼
┌──────────────────┐
│ Detect install │ editable? pipx? venv? locked-env?
│ kind │
└─────────┬────────┘
│
editable / locked-env / unknown
│
▼
┌──────────────────┐
│ Refuse + hint │ message tailored to install kind
└──────────────────┘
venv (the happy path)
│
▼
┌──────────────────┐
│ Take snapshot │ pip freeze + metadata
└─────────┬────────┘
│
▼
┌──────────────────┐
│ pip install -U │ --upgrade evi[==<version>]
└─────────┬────────┘
│
exit 0?
│
┌─────────┴────────┐
│ │
no yes
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Surface stderr │ │ Verify import │ subprocess: python -c "import evi"
│ + snapshot kept │ └─────────┬────────┘
│ + hint rollback │ │
└──────────────────┘ version matches?
│
┌───────┴────────┐
│ │
no yes
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Auto-rollback? │ │ GC old snapshots│
│ Or warn + hint │ │ + done │
└──────────────────┘ └──────────────────┘
Install-kind detection
We check, in order:
- Editable — parse the output of
pip show evi-assistant. If it has anEditable project location:line, refuse with the editable location printed and a hint togit pullinstead. - pipx — if
PIPX_HOMEis in env ORwhich eviresolves under~/.local/pipx/venvs/evi-assistant/, suggestpipx upgrade evi-assistantrather than running pip ourselves. - Locked env — if cwd (or any parent up to
$HOME) has apoetry.lock/uv.lock/Pipfile.lock, suggest the right tool's upgrade command. The user can pass--forceto override. - Plain venv / system pip — the happy path. Run
pip install --upgrade.
The pip call
subprocess.run(
[sys.executable, "-m", "pip", "install", "--upgrade", spec],
check=False, capture_output=True, text=True, timeout=600,
)
spec is either evi-assistant (latest) or evi-assistant==<version>. sys.executable guarantees we install into the SAME interpreter that's running evi, which is critical — using bare pip could pick up a different venv.
If --from-wheel <path> is used, spec becomes the path; pip handles wheels and sdists transparently.
Verification
After pip exits 0, we spawn:
python -c "import evi; print(evi.__version__)"
If that fails or returns the OLD version, we either:
- Interactive: ask the user "broken — roll back? [Y/n]"
--yes: roll back automatically and exit non-zero with a clear error.
This catches the case where pip happily "succeeded" but the installed package can't import (e.g. a dep version conflict that pip didn't catch because eVi doesn't pin it strictly).
Rollback
subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", snapshot / "requirements.txt"],
check=True, timeout=600,
)
Then re-verify the import.
Note: we restore ALL frozen packages, not just eVi. That's intentional — if the failure was caused by a transitive bump, just reinstalling eVi at the old version won't help. Restoring the full pip freeze is the only way to get a deterministic rollback.
Risk: if the user installed something else in the venv between the snapshot and now, the rollback wipes that. Mitigation: print the requirements.txt diff before running and prompt to confirm. With --yes, skip the prompt — the user explicitly opted in.
Implementation skeleton
evi/
update.py
─ check_pypi() → LatestInfo {version, release_url, changelog_url}
─ detect_install_kind() → "editable" | "pipx" | "locked" | "venv"
─ Snapshot dataclass {dir, timestamp, from_version, to_version}
─ create_snapshot(from_version, to_version) → Snapshot
─ list_snapshots() → list[Snapshot]
─ apply_upgrade(spec, *, dry_run=False) → UpgradeResult
─ apply_rollback(snapshot) → RollbackResult
─ verify_install() → (ok, installed_version, err)
─ gc_snapshots(keep=5) → list[deleted]
evi/apps/cli/main.py
─ update_app = typer.Typer()
─ @update_app.command("check") → check_pypi() + render
─ @update_app.command() → check + prompt + apply_upgrade
─ @update_app.command("rollback")
─ @update_app.command("history")
─ @update_app.command("prune")
─ @update_app.command("from-wheel")
─ @update_app.command("settings")
Tests
- Mock
httpx.get("https://pypi.org/pypi/evi-assistant/json")to return canned payloads (newer / same / older / 404). - Mock
subprocess.runfor pip calls — verify command shape, capture exit codes, simulate success + failure. - Real
tmp_path-rooted snapshot dirs to exercise create / list / gc / rollback dispatch. detect_install_kindagainst syntheticpip showoutput.
Open questions
- Should rollback be one-step or stepped? Right now I propose "restore the full pip freeze". Alternative: only downgrade eVi itself, leave deps alone. Cleaner but doesn't recover from transitive bumps. I lean toward "full freeze, with a confirmation".
- Where do PyPI release notes come from? The PyPI JSON API exposes
description(the README of that release). We could surface that verbatim, or link to GitHub's release page. Probably both. - Auto-update via the scheduler? Could plug into the existing
evi/scheduler.py. Defer — feels too aggressive without a "stable for N days" policy first. - Versioning of snapshots themselves. Right now I propose a directory-name convention; a small
snapshots.jsonindex would makelistfaster and more reliable. Worth doing from day 1.
Estimated scope
- Implementation: ~400 LOC in
evi/update.py, ~200 LOC of CLI wiring. - Tests: ~300 LOC.
- Total: a short phase, comparable to Phase 22 (distribution tooling).
- No new deps — uses stdlib
subprocess,httpx(already in core), and the user's installedpip.