change: derive install state from .git and the build stamp
hermes_cli/runtime_tree.py replaces install_manifest.py. A tree with .git is a git checkout and `hermes update` owns it. A tree without .git is sealed, and the distribution field of the build stamp names the steward that replaces it (desktop-app, docker, nix). The refusal message comes from a per-steward table. .hermes-install.json dies: staging stops writing it into the payload, the CLI never reads it, and the update channel lives in config.yaml (update.channel; main is the default and keeps the current behavior). Eject gates on Sealed(desktop-app) and is a full handoff: it tells the user that Setup replaces the desktop app. --channel on a git checkout writes config instead of a manifest.
This commit is contained in:
parent
60372c1630
commit
eb676fe66e
|
|
@ -320,19 +320,6 @@ function stageRepo(tag, outDir) {
|
|||
"--source", "ci",
|
||||
"--distribution", "desktop-app",
|
||||
])
|
||||
// The install manifest is BUILD metadata for a resident bundle: the
|
||||
// payload repo is always desktop-managed, always the stable channel,
|
||||
// always pinned to this tag. Shipping it statically means the Python
|
||||
// side (update refusal, eject, channel vocabulary) reads the same file
|
||||
// in a resident bundle as in a materialized checkout.
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, ".hermes-install.json"),
|
||||
JSON.stringify(
|
||||
{ schemaVersion: 1, installMode: "bundled", channel: "stable", manageStyle: "adopted", pinnedTag: tag },
|
||||
null,
|
||||
2
|
||||
) + "\n"
|
||||
)
|
||||
return commit
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2459,13 +2459,12 @@ DEFAULT_CONFIG = {
|
|||
# Settings for the update pipeline.
|
||||
"update": {
|
||||
# This setting selects the releases that `hermes update` tracks on
|
||||
# source (git) installs:
|
||||
# auto — use the install manifest (.hermes-install.json).
|
||||
# This is the same as "main" for each pre-existing install.
|
||||
# main — git pull origin main (the current behavior).
|
||||
# git checkouts:
|
||||
# main — git pull origin main (the default).
|
||||
# stable — check out the latest tagged release, not main.
|
||||
# Bundled desktop installs ignore this setting and always track
|
||||
# stable. Their updates come from the updater of the desktop app.
|
||||
# auto — same as main (kept for pre-existing configs).
|
||||
# Sealed installs (the embedded desktop app, docker, nix) ignore
|
||||
# this setting; their stewards own versioning.
|
||||
"channel": "auto",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
"""Install-manifest functions for bundled desktop installs.
|
||||
|
||||
``.hermes-install.json`` is a small marker file next to the managed checkout.
|
||||
It sits in the parent directory of ``hermes_cli/``. This is the same anchor
|
||||
rule as ``.install_method`` in ``hermes_cli/config.py``. The marker describes
|
||||
the running code, not ``$HERMES_HOME``. Two installs that share one data
|
||||
directory cannot overwrite each other's marker.
|
||||
|
||||
The file records where a checkout came from and where its updates come from:
|
||||
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"installMode": "bundled" | "source",
|
||||
"channel": "stable" | "main",
|
||||
"manageStyle": "adopted" | "ejected", # optional
|
||||
"pinnedCommit": "<sha>", # optional
|
||||
"pinnedTag": "v0.17.0" # optional, bundled installs only
|
||||
}
|
||||
|
||||
Semantics
|
||||
---------
|
||||
* ``installMode: "source"`` — the user manages the checkout. ``hermes update``
|
||||
owns updates (git pull, tag checkout, or ZIP fallback). **A missing file
|
||||
means source mode.** Every install that exists today is a source install.
|
||||
No migration is necessary.
|
||||
* ``installMode: "bundled"`` — the checkout came from payloads inside the
|
||||
desktop installer. The desktop app owns updates. It rebuilds the checkout
|
||||
offline after the app updates itself. ``hermes update`` refuses and points
|
||||
at the in-app updater.
|
||||
* ``channel`` — ``"main"`` follows the git main branch (source mode only).
|
||||
``"stable"`` follows tagged releases. :func:`resolve_update_channel` gives
|
||||
the effective channel. ``update.channel`` in config.yaml can override the
|
||||
channel for source installs. Bundled installs are always stable.
|
||||
* ``manageStyle`` — how the install got into its current mode.
|
||||
``installMode`` says where it is now. Values:
|
||||
|
||||
- ``"adopted"`` — the install is desktop-managed (a resident bundle's
|
||||
static manifest, or an installer run that selected bundled mode).
|
||||
- ``"ejected"`` — the user ran ``hermes update --eject``. This opt-out is
|
||||
permanent, although the resulting ``installMode`` is ``"source"``.
|
||||
- missing — a legacy checkout from before manifests, or a plain source
|
||||
install.
|
||||
|
||||
This is a pure-stdlib leaf module. It does not import hermes_cli.config.
|
||||
A config import would pull the full config machinery into every consumer.
|
||||
The desktop bootstrap and the install scripts also write this file without
|
||||
Python.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INSTALL_MANIFEST_NAME = ".hermes-install.json"
|
||||
INSTALL_MANIFEST_SCHEMA_VERSION = 1
|
||||
|
||||
MODE_SOURCE = "source"
|
||||
MODE_BUNDLED = "bundled"
|
||||
_VALID_MODES = (MODE_SOURCE, MODE_BUNDLED)
|
||||
|
||||
CHANNEL_MAIN = "main"
|
||||
CHANNEL_STABLE = "stable"
|
||||
_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE)
|
||||
|
||||
STYLE_ADOPTED = "adopted"
|
||||
STYLE_EJECTED = "ejected"
|
||||
_VALID_STYLES = (STYLE_ADOPTED, STYLE_EJECTED)
|
||||
|
||||
|
||||
def _default_manifest() -> dict:
|
||||
"""The implicit manifest for a checkout with no ``.hermes-install.json``.
|
||||
|
||||
Source mode on the main channel. This is the current behavior, so
|
||||
installs from before manifests do not change.
|
||||
"""
|
||||
return {
|
||||
"schemaVersion": INSTALL_MANIFEST_SCHEMA_VERSION,
|
||||
"installMode": MODE_SOURCE,
|
||||
"channel": CHANNEL_MAIN,
|
||||
}
|
||||
|
||||
|
||||
def install_manifest_path(project_root: Optional[Path] = None) -> Path:
|
||||
"""Path of the manifest for the running code's install tree."""
|
||||
root = project_root if project_root is not None else Path(__file__).parent.parent
|
||||
return Path(root).resolve() / INSTALL_MANIFEST_NAME
|
||||
|
||||
|
||||
def read_install_manifest(project_root: Optional[Path] = None) -> dict:
|
||||
"""Read the install manifest and correct bad values.
|
||||
|
||||
This function does not raise errors. A missing, unreadable, or malformed
|
||||
file falls back to the source/main default. Unknown ``installMode`` or
|
||||
``channel`` values (for example, from a future Hermes with more values)
|
||||
also fall back. This keeps the update logic safe. The function keeps
|
||||
unknown extra keys, so a round trip of a future manifest does not remove
|
||||
fields.
|
||||
"""
|
||||
path = install_manifest_path(project_root)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return _default_manifest()
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Unreadable install manifest at %s (%s); assuming source install", path, exc)
|
||||
return _default_manifest()
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("Install manifest at %s is not a JSON object; assuming source install", path)
|
||||
return _default_manifest()
|
||||
|
||||
manifest = dict(raw)
|
||||
if manifest.get("installMode") not in _VALID_MODES:
|
||||
manifest["installMode"] = MODE_SOURCE
|
||||
if manifest.get("channel") not in _VALID_CHANNELS:
|
||||
manifest["channel"] = CHANNEL_MAIN if manifest["installMode"] == MODE_SOURCE else CHANNEL_STABLE
|
||||
# Drop unknown manageStyle values. Do not replace them with a default.
|
||||
# A missing style means "adoption can examine this checkout". An invented
|
||||
# style can block adoption in error, or worse, remove an eject opt-out
|
||||
# that a future vocabulary wrote. Exception: a value that contains
|
||||
# "eject" stays ejected. The opt-out must survive vocabulary changes in
|
||||
# both directions.
|
||||
style = manifest.get("manageStyle")
|
||||
if style is not None and style not in _VALID_STYLES:
|
||||
if isinstance(style, str) and "eject" in style.lower():
|
||||
manifest["manageStyle"] = STYLE_EJECTED
|
||||
else:
|
||||
del manifest["manageStyle"]
|
||||
manifest.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION)
|
||||
return manifest
|
||||
|
||||
|
||||
def write_install_manifest(
|
||||
manifest: dict,
|
||||
project_root: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Write the manifest atomically (tmp file + rename). Return the path.
|
||||
|
||||
The function validates mode, channel, and style before the write. A bad
|
||||
write is worse than an error: every reader silently converts bad values
|
||||
to source/main, and the caller's intent (for example, to mark an install
|
||||
bundled) is lost without a signal.
|
||||
"""
|
||||
if manifest.get("installMode") not in _VALID_MODES:
|
||||
raise ValueError(f"invalid installMode: {manifest.get('installMode')!r}")
|
||||
if manifest.get("channel") not in _VALID_CHANNELS:
|
||||
raise ValueError(f"invalid channel: {manifest.get('channel')!r}")
|
||||
if manifest.get("manageStyle") is not None and manifest["manageStyle"] not in _VALID_STYLES:
|
||||
raise ValueError(f"invalid manageStyle: {manifest.get('manageStyle')!r}")
|
||||
|
||||
payload = dict(manifest)
|
||||
payload.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION)
|
||||
|
||||
path = install_manifest_path(project_root)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
return path
|
||||
|
||||
|
||||
def is_bundled_install(project_root: Optional[Path] = None) -> bool:
|
||||
"""True when the running checkout came from desktop payloads."""
|
||||
return read_install_manifest(project_root).get("installMode") == MODE_BUNDLED
|
||||
|
||||
|
||||
def is_ejected(project_root: Optional[Path] = None) -> bool:
|
||||
"""True when the user ejected this checkout from desktop management.
|
||||
|
||||
This is the permanent opt-out signal for auto-adoption. Auto-adoption
|
||||
must not move an ejected checkout back into the bundled path, although
|
||||
its ``installMode`` is ``source``.
|
||||
"""
|
||||
return read_install_manifest(project_root).get("manageStyle") == STYLE_EJECTED
|
||||
|
||||
|
||||
def resolve_update_channel(
|
||||
config: Optional[dict] = None,
|
||||
project_root: Optional[Path] = None,
|
||||
) -> str:
|
||||
"""Give the effective update channel for this install.
|
||||
|
||||
Resolution order:
|
||||
1. Bundled installs are always ``stable``. The desktop app rebuilds the
|
||||
checkout from tagged release payloads. A config override cannot change
|
||||
what the installer ships. Eject first to change this.
|
||||
2. ``update.channel`` from config.yaml, when it is ``stable`` or ``main``.
|
||||
The values ``auto``, empty, and unknown fall through.
|
||||
3. The channel from the manifest. The source default is ``main``.
|
||||
"""
|
||||
manifest = read_install_manifest(project_root)
|
||||
if manifest.get("installMode") == MODE_BUNDLED:
|
||||
return CHANNEL_STABLE
|
||||
|
||||
configured: Any = None
|
||||
if isinstance(config, dict):
|
||||
update_cfg = config.get("update")
|
||||
if isinstance(update_cfg, dict):
|
||||
configured = update_cfg.get("channel")
|
||||
if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS:
|
||||
return configured.strip().lower()
|
||||
|
||||
return manifest.get("channel", CHANNEL_MAIN)
|
||||
|
||||
|
||||
def format_bundled_update_message() -> str:
|
||||
"""Refusal text for ``hermes update`` on a bundled install."""
|
||||
return (
|
||||
"✗ The Hermes desktop app manages this Hermes install.\n"
|
||||
"\n"
|
||||
"The desktop app updates the agent together with itself. Use the\n"
|
||||
"in-app updater (Settings → Check for updates), not `hermes update`.\n"
|
||||
"\n"
|
||||
"If you want to manage this checkout yourself with `hermes update`,\n"
|
||||
"eject it from desktop management first. After an eject, the desktop\n"
|
||||
"app continues to update itself, but the agent checkout is yours."
|
||||
)
|
||||
|
|
@ -9192,16 +9192,14 @@ def cmd_update(args):
|
|||
|
||||
sys.exit(cmd_update_eject(args))
|
||||
|
||||
# Bundled desktop installs are materialized from payloads shipped inside
|
||||
# the desktop app. The updater of the app re-materializes the checkout
|
||||
# after the app updates itself. If `hermes update` changes that checkout,
|
||||
# the checkout no longer agrees with the stamped tag of the shell. Thus
|
||||
# refuse, and point at the in-app updater or at eject. Eject changes the
|
||||
# install to source mode.
|
||||
from hermes_cli.install_manifest import format_bundled_update_message, is_bundled_install
|
||||
# A tree without .git is sealed: a steward (the desktop app, docker,
|
||||
# nix, a package manager) replaces it wholesale, and `hermes update`
|
||||
# has nothing to update. Refuse and name the steward's mechanism.
|
||||
from hermes_cli.runtime_tree import Sealed, runtime_tree, steward_update_message
|
||||
|
||||
if is_bundled_install(PROJECT_ROOT):
|
||||
print(format_bundled_update_message())
|
||||
tree = runtime_tree(PROJECT_ROOT)
|
||||
if isinstance(tree, Sealed):
|
||||
print(steward_update_message(tree.steward))
|
||||
sys.exit(1)
|
||||
|
||||
if getattr(args, "check", False):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
"""Derive who owns the running tree — no stored mode flags.
|
||||
|
||||
The install model has two axes (design:
|
||||
.hermes/plans/2026-08-07_183000-two-axis-install-model.md):
|
||||
|
||||
* A tree with ``.git`` is a **git checkout**: ``hermes update`` owns it.
|
||||
The checkout's existence IS the fact; no manifest records it.
|
||||
* A tree without ``.git`` is **sealed**: something external replaces it
|
||||
wholesale. The build stamp (``.hermes_build_info.json``) names that
|
||||
steward in its ``distribution`` field: ``desktop-app`` (the embedded
|
||||
desktop bundle), ``docker``, ``nix``, or a future package manager.
|
||||
|
||||
The update channel (``stable`` or ``main``) lives in config.yaml under
|
||||
``update.channel``. It applies to git checkouts only — sealed trees
|
||||
version-track through their stewards.
|
||||
|
||||
If a future feature writes to user checkouts (nothing does today), it
|
||||
must add an explicit opt-out fact FIRST. The old ``manageStyle: ejected``
|
||||
stickiness guarded against desktop-side adoption and rematerialization;
|
||||
both are deleted, so the guard went with them.
|
||||
|
||||
This is a pure-stdlib leaf module. It does not import hermes_cli.config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
BUILD_INFO_NAME = ".hermes_build_info.json"
|
||||
|
||||
STEWARD_DESKTOP = "desktop-app"
|
||||
STEWARD_DOCKER = "docker"
|
||||
STEWARD_NIX = "nix"
|
||||
|
||||
CHANNEL_MAIN = "main"
|
||||
CHANNEL_STABLE = "stable"
|
||||
_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE)
|
||||
|
||||
# What `hermes update` says in a sealed tree, per steward. The fallback
|
||||
# covers stewards this build does not know (a newer package-manager value
|
||||
# read by older code).
|
||||
STEWARD_UPDATE_MESSAGES = {
|
||||
STEWARD_DESKTOP: (
|
||||
"✗ This Hermes runs from inside the desktop app bundle.\n"
|
||||
"\n"
|
||||
"The app updates itself, and every app update carries the agent\n"
|
||||
"with it. There is nothing for `hermes update` to do here.\n"
|
||||
"\n"
|
||||
"To manage the agent from the command line with git, run\n"
|
||||
"`hermes update --eject`. It installs a source checkout and a\n"
|
||||
"desktop app built from it."
|
||||
),
|
||||
STEWARD_DOCKER: (
|
||||
"✗ This Hermes runs from a Docker image.\n"
|
||||
"\n"
|
||||
"The image is immutable. Pull the new image to update:\n"
|
||||
" docker pull nousresearch/hermes-agent:latest"
|
||||
),
|
||||
STEWARD_NIX: (
|
||||
"✗ This Hermes runs from the Nix store.\n"
|
||||
"\n"
|
||||
"The store path is immutable. Update through your flake:\n"
|
||||
" nix flake update && rebuild your profile or system"
|
||||
),
|
||||
}
|
||||
|
||||
_STEWARD_FALLBACK_MESSAGE = (
|
||||
"✗ This Hermes install is managed by {steward}.\n"
|
||||
"\n"
|
||||
"The tree has no git checkout, so `hermes update` cannot update it.\n"
|
||||
"Update it with the tool that installed it."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCheckout:
|
||||
"""A tree with .git — `hermes update` owns it."""
|
||||
|
||||
root: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sealed:
|
||||
"""A gitless tree — the steward replaces it wholesale."""
|
||||
|
||||
root: Path
|
||||
steward: str
|
||||
|
||||
|
||||
def read_build_info(project_root: Path) -> dict:
|
||||
"""The baked build stamp of ``project_root``, or ``{}``."""
|
||||
try:
|
||||
data = json.loads((Path(project_root) / BUILD_INFO_NAME).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def runtime_tree(project_root: Path) -> GitCheckout | Sealed:
|
||||
"""Classify the tree at ``project_root``.
|
||||
|
||||
``.git`` present (a directory, or a worktree/submodule gitfile) means a
|
||||
git checkout. Everything else is sealed, with the steward read from the
|
||||
build stamp; a missing or unknown stamp gives steward ``"unknown"``.
|
||||
"""
|
||||
root = Path(project_root)
|
||||
if (root / ".git").exists():
|
||||
return GitCheckout(root=root)
|
||||
|
||||
distribution = read_build_info(root).get("distribution")
|
||||
steward = distribution if isinstance(distribution, str) and distribution else "unknown"
|
||||
return Sealed(root=root, steward=steward)
|
||||
|
||||
|
||||
def steward_update_message(steward: str) -> str:
|
||||
"""The `hermes update` refusal text for a sealed tree."""
|
||||
message = STEWARD_UPDATE_MESSAGES.get(steward)
|
||||
if message is not None:
|
||||
return message
|
||||
return _STEWARD_FALLBACK_MESSAGE.format(steward=steward)
|
||||
|
||||
|
||||
def managed_install_roots() -> tuple[Path, ...]:
|
||||
"""The canonical roots where installers create the agent checkout.
|
||||
|
||||
* per-user: ``$HERMES_HOME/hermes-agent`` (usually ``~/.hermes``)
|
||||
* FHS root installs (install.sh as root on Linux):
|
||||
``/usr/local/lib/hermes-agent``
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return (get_hermes_home() / "hermes-agent", Path("/usr/local/lib/hermes-agent"))
|
||||
|
||||
|
||||
def is_managed_install_root(path: Path) -> bool:
|
||||
"""True when ``path`` is a canonical installer-created checkout root.
|
||||
|
||||
`hermes update` updates these without a question. A checkout anywhere
|
||||
else is somebody's working tree, and update asks first.
|
||||
"""
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
except OSError:
|
||||
return False
|
||||
for root in managed_install_roots():
|
||||
try:
|
||||
if resolved == root.resolve():
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def resolve_update_channel(config: Optional[dict] = None) -> str:
|
||||
"""The effective update channel for a git checkout.
|
||||
|
||||
``update.channel`` from config.yaml when it is ``stable`` or ``main``;
|
||||
anything else (missing, ``auto``, unknown) means ``main``. Sealed trees
|
||||
never ask: their stewards own versioning.
|
||||
"""
|
||||
configured = None
|
||||
if isinstance(config, dict):
|
||||
update_cfg = config.get("update")
|
||||
if isinstance(update_cfg, dict):
|
||||
configured = update_cfg.get("channel")
|
||||
if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS:
|
||||
return configured.strip().lower()
|
||||
return CHANNEL_MAIN
|
||||
|
|
@ -239,22 +239,21 @@ def _stable_channel_active(args) -> bool:
|
|||
branch flag to honor. An explicit ``--branch`` always wins. With this flag
|
||||
the user tells us the exact update target. If a tag silently overrides the
|
||||
flag, the class of bug that --branch prevents comes back. In all other
|
||||
cases the effective channel comes from the install manifest and
|
||||
``update.channel`` in config.yaml
|
||||
(see hermes_cli.install_manifest.resolve_update_channel).
|
||||
cases the effective channel comes from ``update.channel`` in config.yaml
|
||||
(see hermes_cli.runtime_tree.resolve_update_channel).
|
||||
"""
|
||||
if getattr(args, "branch", None):
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.install_manifest import CHANNEL_STABLE, resolve_update_channel
|
||||
from hermes_cli.runtime_tree import CHANNEL_STABLE, resolve_update_channel
|
||||
|
||||
config = None
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load config for channel resolution: %s", exc)
|
||||
return resolve_update_channel(config, _m().PROJECT_ROOT) == CHANNEL_STABLE
|
||||
return resolve_update_channel(config) == CHANNEL_STABLE
|
||||
except Exception as exc:
|
||||
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
|
||||
return False
|
||||
|
|
@ -4024,19 +4023,18 @@ def _normalize_managed_eol(git_cmd, repo_root):
|
|||
pass
|
||||
|
||||
|
||||
def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
|
||||
"""Eject a resident bundle: hand the install to Hermes Setup.
|
||||
def _eject_embedded_bundle(bundle_repo_root: Path, tag_label: str) -> int:
|
||||
"""Eject an embedded bundle: a full handoff to Hermes Setup.
|
||||
|
||||
The resident bundle is sealed (codesigned resources); no git graft is
|
||||
The embedded bundle is sealed (codesigned resources); no git graft is
|
||||
possible or wanted, and hand-rolling clone+venv here would duplicate
|
||||
the installer badly (no PATH setup, no config templates, no system
|
||||
checks). Instead this downloads the official Hermes Setup app from the
|
||||
website and launches it pinned to the EXACT commit this bundle was
|
||||
built from (.hermes_build_info.json), so the ejected source checkout
|
||||
matches the code the user is running. The installer then performs a
|
||||
normal source install at ~/.hermes/hermes-agent; the desktop prefers
|
||||
that checkout on its next launch. Network is required — the same
|
||||
contract as every other eject.
|
||||
checks). This downloads the official Hermes Setup app from the website
|
||||
and launches it pinned to the EXACT commit this bundle was built from
|
||||
(.hermes_build_info.json), so the ejected source checkout matches the
|
||||
code the user runs. Setup then performs a normal source install at
|
||||
~/.hermes/hermes-agent AND builds a source-managed desktop app from it
|
||||
— that app replaces the embedded one. Network is required.
|
||||
|
||||
Returns a process exit code.
|
||||
"""
|
||||
|
|
@ -4044,20 +4042,17 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
|
|||
print("\u2717 Hermes Setup is only published for macOS and Windows.")
|
||||
print(" Install from source instead:")
|
||||
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
|
||||
print(" The desktop app will prefer that checkout on its next launch.")
|
||||
return 1
|
||||
|
||||
target = get_hermes_home() / "hermes-agent"
|
||||
existing = _read_json_or_none(target / ".hermes-install.json")
|
||||
if (target / ".git").exists() and (existing is None or existing.get("installMode") == "source"):
|
||||
if (target / ".git").exists():
|
||||
print(f"\u2713 A source checkout already exists at {target}.")
|
||||
print(" The desktop app will use it on its next launch.")
|
||||
print(" Update it with: hermes update")
|
||||
return 0
|
||||
|
||||
# The exact commit of this bundle. The pinned tag is the fallback label
|
||||
# for the message; the pin itself must be a commit sha because tags can
|
||||
# be re-pointed but the sha names what this bundle actually runs.
|
||||
# The exact commit of this bundle. The tag is the display label; the
|
||||
# pin itself must be a commit sha because tags can be re-pointed but
|
||||
# the sha names what this bundle actually runs.
|
||||
build_info = _read_json_or_none(bundle_repo_root / ".hermes_build_info.json") or {}
|
||||
commit = str(build_info.get("commit") or "")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
|
|
@ -4081,7 +4076,7 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
|
|||
print("\u2717 The download failed. Hermes aborted the eject. The install is unchanged.")
|
||||
return 1
|
||||
|
||||
print(f"\u2192 Hermes starts the installer, pinned to {pinned_tag} ({commit[:12]})...")
|
||||
print(f"\u2192 Hermes starts the installer, pinned to {tag_label} ({commit[:12]})...")
|
||||
ok = _launch_hermes_setup(setup_path, scratch, commit)
|
||||
if not ok:
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
|
@ -4089,9 +4084,12 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
|
|||
print(f" The downloaded file is gone; get it manually: {setup_url}")
|
||||
return 1
|
||||
|
||||
print("\u2713 Hermes Setup is running. Follow its window to finish the eject.")
|
||||
print(f" \u2022 It installs a source-managed checkout at {target}")
|
||||
print(" \u2022 The desktop app will use that checkout on its next launch.")
|
||||
print("\u2713 Hermes Setup is running. This is a full handoff:")
|
||||
print(f" \u2022 It installs a source checkout at {target}")
|
||||
print(" \u2022 It builds a new desktop app from that checkout and")
|
||||
print(" replaces this one.")
|
||||
print(" \u2022 Close the Hermes desktop app now, so that Setup can")
|
||||
print(" replace it.")
|
||||
print(" \u2022 After the install, update with: hermes update")
|
||||
return 0
|
||||
|
||||
|
|
@ -4174,65 +4172,58 @@ def _launch_hermes_setup(setup_path: Path, scratch: Path, commit: str) -> bool:
|
|||
def cmd_update_eject(args) -> int:
|
||||
"""Implement ``hermes update --eject``.
|
||||
|
||||
A desktop-bundled install runs the agent out of the sealed app bundle
|
||||
(resident mode). The eject creates a normal source install beside it:
|
||||
it downloads Hermes Setup from the website and launches it pinned to
|
||||
the exact commit this bundle was built from. The desktop app prefers
|
||||
the resulting source checkout on its next launch. On an install that
|
||||
is already source-managed, the command only switches the channel.
|
||||
An embedded desktop install runs the agent out of the sealed app
|
||||
bundle. The eject is a full handoff to Hermes Setup: it installs a
|
||||
source checkout and a desktop app built from it, and that app
|
||||
replaces the embedded one. On a git checkout, the command only
|
||||
switches the update channel in config.yaml.
|
||||
|
||||
Returns a process exit code.
|
||||
"""
|
||||
from hermes_cli.install_manifest import (
|
||||
from hermes_cli.runtime_tree import (
|
||||
CHANNEL_MAIN,
|
||||
CHANNEL_STABLE,
|
||||
MODE_SOURCE,
|
||||
STYLE_EJECTED,
|
||||
install_manifest_path,
|
||||
read_install_manifest,
|
||||
write_install_manifest,
|
||||
STEWARD_DESKTOP,
|
||||
GitCheckout,
|
||||
runtime_tree,
|
||||
)
|
||||
|
||||
project_root = _m().PROJECT_ROOT
|
||||
manifest = read_install_manifest(project_root)
|
||||
channel = getattr(args, "channel", None) or CHANNEL_MAIN
|
||||
if channel not in (CHANNEL_MAIN, CHANNEL_STABLE):
|
||||
print(f"✗ Unknown channel '{channel}'. Use 'stable' or 'main'.")
|
||||
print(f"\u2717 Unknown channel '{channel}'. Use 'stable' or 'main'.")
|
||||
return 1
|
||||
|
||||
if manifest.get("installMode") != "bundled":
|
||||
# The install is already source-managed. Obey an explicit --channel
|
||||
tree = runtime_tree(project_root)
|
||||
if isinstance(tree, GitCheckout):
|
||||
# The install is already git-managed. Obey an explicit --channel
|
||||
# request, so that `hermes update --eject --channel stable` is a
|
||||
# one-shot way to switch. But do not touch the git history.
|
||||
if getattr(args, "channel", None):
|
||||
manifest["installMode"] = MODE_SOURCE
|
||||
manifest["channel"] = channel
|
||||
# We do not set the ejected mark here. This shorthand runs on
|
||||
# checkouts that the desktop never managed, or on checkouts that
|
||||
# the user already ejected. In the second case, the code below
|
||||
# keeps the existing style. A plain channel switch is not an
|
||||
# adoption opt-out.
|
||||
write_install_manifest(manifest, project_root)
|
||||
print(f"✓ The install is already source-managed. The channel is now '{channel}'.")
|
||||
from hermes_cli.config import set_config_value
|
||||
|
||||
set_config_value("update.channel", channel)
|
||||
print(f"\u2713 The install is already git-managed. The channel is now '{channel}'.")
|
||||
else:
|
||||
print("✓ Nothing to eject. This install is already source-managed.")
|
||||
print(" (Only desktop-bundled installs need an eject.)")
|
||||
print("\u2713 Nothing to eject. This install is already git-managed.")
|
||||
print(" (Only embedded desktop installs need an eject.)")
|
||||
return 0
|
||||
|
||||
pinned_tag = manifest.get("pinnedTag") or ""
|
||||
if not re.fullmatch(r"v(0|[1-9]\d{0,2})\.\d+\.\d+", pinned_tag):
|
||||
print("✗ An eject is not possible. The install manifest has no valid pinned tag.")
|
||||
print(" Reinstall from source instead:")
|
||||
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
|
||||
if tree.steward != STEWARD_DESKTOP:
|
||||
from hermes_cli.runtime_tree import steward_update_message
|
||||
|
||||
print(steward_update_message(tree.steward))
|
||||
return 1
|
||||
|
||||
# Hermes Setup writes the ejected checkout's own manifest, so an explicit
|
||||
# Hermes Setup writes the ejected checkout's channel, so an explicit
|
||||
# --channel cannot apply here. Say so instead of dropping it silently.
|
||||
if getattr(args, "channel", None):
|
||||
print(f"⚠ --channel {channel} does not apply to this eject.")
|
||||
print(f"\u26a0 --channel {channel} does not apply to this eject.")
|
||||
print(" After the install, set it with: hermes update --eject --channel " + channel)
|
||||
|
||||
return _eject_resident_bundle(project_root, pinned_tag)
|
||||
build_info = _read_json_or_none(Path(project_root) / ".hermes_build_info.json") or {}
|
||||
tag_label = str(build_info.get("tag") or build_info.get("displayVersion") or "this release")
|
||||
return _eject_embedded_bundle(Path(project_root), tag_label)
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
|
|
|||
|
|
@ -1,207 +0,0 @@
|
|||
"""Tests for hermes_cli/install_manifest.py — bundled-install manifest plumbing."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.install_manifest import (
|
||||
CHANNEL_MAIN,
|
||||
CHANNEL_STABLE,
|
||||
INSTALL_MANIFEST_NAME,
|
||||
MODE_BUNDLED,
|
||||
MODE_SOURCE,
|
||||
STYLE_ADOPTED,
|
||||
STYLE_EJECTED,
|
||||
format_bundled_update_message,
|
||||
install_manifest_path,
|
||||
is_bundled_install,
|
||||
is_ejected,
|
||||
read_install_manifest,
|
||||
resolve_update_channel,
|
||||
write_install_manifest,
|
||||
)
|
||||
|
||||
|
||||
def _write_raw(tmp_path, payload):
|
||||
(tmp_path / INSTALL_MANIFEST_NAME).write_text(
|
||||
payload if isinstance(payload, str) else json.dumps(payload),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestReadInstallManifest:
|
||||
def test_absent_file_means_source_main(self, tmp_path):
|
||||
"""The back-compat contract: no manifest ⇒ source install on main."""
|
||||
manifest = read_install_manifest(tmp_path)
|
||||
assert manifest["installMode"] == MODE_SOURCE
|
||||
assert manifest["channel"] == CHANNEL_MAIN
|
||||
|
||||
def test_malformed_json_degrades_to_source(self, tmp_path):
|
||||
_write_raw(tmp_path, "{not json")
|
||||
assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE
|
||||
|
||||
def test_non_object_json_degrades_to_source(self, tmp_path):
|
||||
_write_raw(tmp_path, '["bundled"]')
|
||||
assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE
|
||||
|
||||
def test_unknown_mode_degrades_to_source(self, tmp_path):
|
||||
"""A future vocabulary must not brick an older reader."""
|
||||
_write_raw(tmp_path, {"installMode": "quantum", "channel": "stable"})
|
||||
manifest = read_install_manifest(tmp_path)
|
||||
assert manifest["installMode"] == MODE_SOURCE
|
||||
assert manifest["channel"] == CHANNEL_STABLE
|
||||
|
||||
def test_unknown_channel_defaults_by_mode(self, tmp_path):
|
||||
_write_raw(tmp_path, {"installMode": "bundled", "channel": "nightly"})
|
||||
assert read_install_manifest(tmp_path)["channel"] == CHANNEL_STABLE
|
||||
_write_raw(tmp_path, {"installMode": "source", "channel": "nightly"})
|
||||
assert read_install_manifest(tmp_path)["channel"] == CHANNEL_MAIN
|
||||
|
||||
def test_extra_keys_preserved(self, tmp_path):
|
||||
_write_raw(
|
||||
tmp_path,
|
||||
{"installMode": "bundled", "channel": "stable", "pinnedTag": "v0.17.0", "futureKey": 7},
|
||||
)
|
||||
manifest = read_install_manifest(tmp_path)
|
||||
assert manifest["pinnedTag"] == "v0.17.0"
|
||||
assert manifest["futureKey"] == 7
|
||||
|
||||
|
||||
class TestWriteInstallManifest:
|
||||
def test_roundtrip(self, tmp_path):
|
||||
write_install_manifest(
|
||||
{"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE, "pinnedTag": "v1.2.3"},
|
||||
tmp_path,
|
||||
)
|
||||
manifest = read_install_manifest(tmp_path)
|
||||
assert manifest["installMode"] == MODE_BUNDLED
|
||||
assert manifest["pinnedTag"] == "v1.2.3"
|
||||
assert manifest["schemaVersion"] == 1
|
||||
|
||||
def test_rejects_invalid_mode(self, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_install_manifest({"installMode": "quantum", "channel": "stable"}, tmp_path)
|
||||
assert not install_manifest_path(tmp_path).exists()
|
||||
|
||||
def test_rejects_invalid_channel(self, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_install_manifest({"installMode": "source", "channel": "nightly"}, tmp_path)
|
||||
|
||||
def test_atomic_no_tmp_leftover(self, tmp_path):
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path)
|
||||
leftovers = [p.name for p in tmp_path.iterdir() if p.name.endswith(".tmp")]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
class TestIsBundledInstall:
|
||||
def test_default_is_not_bundled(self, tmp_path):
|
||||
assert not is_bundled_install(tmp_path)
|
||||
|
||||
def test_bundled_manifest_detected(self, tmp_path):
|
||||
write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path)
|
||||
assert is_bundled_install(tmp_path)
|
||||
|
||||
|
||||
class TestManageStyle:
|
||||
def test_absent_by_default(self, tmp_path):
|
||||
assert "manageStyle" not in read_install_manifest(tmp_path)
|
||||
assert not is_ejected(tmp_path)
|
||||
|
||||
def test_valid_styles_roundtrip(self, tmp_path):
|
||||
for style in (STYLE_ADOPTED, STYLE_EJECTED):
|
||||
write_install_manifest(
|
||||
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": style},
|
||||
tmp_path,
|
||||
)
|
||||
assert read_install_manifest(tmp_path)["manageStyle"] == style
|
||||
|
||||
def test_ejected_is_sticky_signal(self, tmp_path):
|
||||
write_install_manifest(
|
||||
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": STYLE_EJECTED},
|
||||
tmp_path,
|
||||
)
|
||||
assert is_ejected(tmp_path)
|
||||
|
||||
def test_unknown_style_dropped_not_defaulted(self, tmp_path):
|
||||
"""A future vocabulary must not wrongly block (or force) adoption."""
|
||||
_write_raw(
|
||||
tmp_path,
|
||||
{"installMode": "source", "channel": "main", "manageStyle": "quantum"},
|
||||
)
|
||||
assert "manageStyle" not in read_install_manifest(tmp_path)
|
||||
|
||||
def test_eject_smelling_future_style_stays_ejected(self, tmp_path):
|
||||
"""The opt-out survives vocabulary drift: 'force-ejected' etc. reads as ejected."""
|
||||
_write_raw(
|
||||
tmp_path,
|
||||
{"installMode": "source", "channel": "main", "manageStyle": "force-ejected"},
|
||||
)
|
||||
assert read_install_manifest(tmp_path)["manageStyle"] == STYLE_EJECTED
|
||||
assert is_ejected(tmp_path)
|
||||
|
||||
def test_write_rejects_invalid_style(self, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_install_manifest(
|
||||
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": "quantum"},
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveUpdateChannel:
|
||||
def test_default_install_is_main(self, tmp_path):
|
||||
assert resolve_update_channel(None, tmp_path) == CHANNEL_MAIN
|
||||
|
||||
def test_bundled_is_always_stable_even_with_config_main(self, tmp_path):
|
||||
write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path)
|
||||
config = {"update": {"channel": "main"}}
|
||||
assert resolve_update_channel(config, tmp_path) == CHANNEL_STABLE
|
||||
|
||||
def test_config_overrides_manifest_on_source(self, tmp_path):
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path)
|
||||
assert resolve_update_channel({"update": {"channel": "stable"}}, tmp_path) == CHANNEL_STABLE
|
||||
|
||||
def test_config_auto_defers_to_manifest(self, tmp_path):
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_STABLE}, tmp_path)
|
||||
assert resolve_update_channel({"update": {"channel": "auto"}}, tmp_path) == CHANNEL_STABLE
|
||||
|
||||
def test_config_garbage_defers_to_manifest(self, tmp_path):
|
||||
assert resolve_update_channel({"update": {"channel": 42}}, tmp_path) == CHANNEL_MAIN
|
||||
assert resolve_update_channel({"update": "stable"}, tmp_path) == CHANNEL_MAIN
|
||||
|
||||
|
||||
class TestDefaultConfigContract:
|
||||
def test_update_channel_key_exists_and_is_valid(self):
|
||||
"""update.channel must exist in DEFAULT_CONFIG with an accepted value."""
|
||||
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
||||
|
||||
channel = DEFAULT_CONFIG["update"]["channel"]
|
||||
assert channel in ("auto", CHANNEL_MAIN, CHANNEL_STABLE)
|
||||
|
||||
|
||||
class TestCmdUpdateRefusal:
|
||||
def test_cmd_update_refuses_on_bundled(self, monkeypatch, capsys):
|
||||
"""cmd_update exits 1 with the bundled message before touching git."""
|
||||
import hermes_cli.install_manifest as im
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
monkeypatch.setattr(im, "is_bundled_install", lambda root=None: True)
|
||||
# Neutralize the earlier refusal branches so we reach the bundled check.
|
||||
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
|
||||
monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root=None: "git")
|
||||
|
||||
class Args:
|
||||
check = False
|
||||
gateway = False
|
||||
branch = None
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
hermes_main.cmd_update(Args())
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "desktop app" in out
|
||||
assert "eject" in out
|
||||
|
||||
def test_message_mentions_in_app_updater(self):
|
||||
msg = format_bundled_update_message()
|
||||
assert "hermes update" in msg
|
||||
assert "eject" in msg
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"""Tests for hermes_cli/runtime_tree.py — tree classification and channel."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.runtime_tree import (
|
||||
CHANNEL_MAIN,
|
||||
CHANNEL_STABLE,
|
||||
STEWARD_UPDATE_MESSAGES,
|
||||
GitCheckout,
|
||||
Sealed,
|
||||
is_managed_install_root,
|
||||
resolve_update_channel,
|
||||
runtime_tree,
|
||||
steward_update_message,
|
||||
)
|
||||
|
||||
|
||||
class TestRuntimeTree:
|
||||
def test_a_tree_with_git_is_a_checkout(self, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
tree = runtime_tree(tmp_path)
|
||||
assert isinstance(tree, GitCheckout)
|
||||
assert tree.root == tmp_path
|
||||
|
||||
def test_a_worktree_gitfile_also_counts(self, tmp_path):
|
||||
# Linked worktrees and submodules have a .git FILE, not a directory.
|
||||
(tmp_path / ".git").write_text("gitdir: /somewhere/else\n")
|
||||
assert isinstance(runtime_tree(tmp_path), GitCheckout)
|
||||
|
||||
def test_a_gitless_tree_is_sealed_with_the_stamped_steward(self, tmp_path):
|
||||
(tmp_path / ".hermes_build_info.json").write_text(
|
||||
json.dumps({"commit": "a" * 40, "distribution": "desktop-app"})
|
||||
)
|
||||
tree = runtime_tree(tmp_path)
|
||||
assert isinstance(tree, Sealed)
|
||||
assert tree.steward == "desktop-app"
|
||||
|
||||
def test_a_gitless_tree_without_a_stamp_is_sealed_unknown(self, tmp_path):
|
||||
tree = runtime_tree(tmp_path)
|
||||
assert isinstance(tree, Sealed)
|
||||
assert tree.steward == "unknown"
|
||||
|
||||
def test_a_corrupt_stamp_degrades_to_unknown(self, tmp_path):
|
||||
(tmp_path / ".hermes_build_info.json").write_text("{not json")
|
||||
tree = runtime_tree(tmp_path)
|
||||
assert isinstance(tree, Sealed)
|
||||
assert tree.steward == "unknown"
|
||||
|
||||
|
||||
class TestStewardMessages:
|
||||
def test_every_known_steward_names_its_mechanism(self):
|
||||
assert "--eject" in steward_update_message("desktop-app")
|
||||
assert "docker pull" in steward_update_message("docker")
|
||||
assert "flake" in steward_update_message("nix")
|
||||
|
||||
def test_an_unknown_steward_gets_the_fallback_with_its_name(self):
|
||||
message = steward_update_message("pacman")
|
||||
assert "pacman" in message
|
||||
assert "cannot update" in message
|
||||
|
||||
def test_every_table_entry_is_a_refusal(self):
|
||||
for steward, message in STEWARD_UPDATE_MESSAGES.items():
|
||||
assert message.startswith("\u2717"), steward
|
||||
|
||||
|
||||
class TestManagedInstallRoot:
|
||||
def test_hermes_home_checkout_is_managed(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
root = tmp_path / ".hermes" / "hermes-agent"
|
||||
root.mkdir(parents=True)
|
||||
assert is_managed_install_root(root) is True
|
||||
|
||||
def test_a_dev_tree_is_not_managed(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
dev = tmp_path / "src" / "hermes-agent"
|
||||
dev.mkdir(parents=True)
|
||||
assert is_managed_install_root(dev) is False
|
||||
|
||||
def test_the_fhs_root_layout_is_managed(self):
|
||||
# /usr/local/lib/hermes-agent need not exist for the answer; the
|
||||
# comparison is by path. If it does not resolve, False is safe.
|
||||
result = is_managed_install_root(Path("/usr/local/lib/hermes-agent"))
|
||||
assert result in (True, False) # never raises
|
||||
# On machines without the dir, resolve() still succeeds (no symlinks
|
||||
# involved), so the comparison holds.
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestResolveUpdateChannel:
|
||||
def test_stable_from_config(self):
|
||||
assert resolve_update_channel({"update": {"channel": "stable"}}) == CHANNEL_STABLE
|
||||
|
||||
def test_main_is_the_default(self):
|
||||
assert resolve_update_channel(None) == CHANNEL_MAIN
|
||||
assert resolve_update_channel({}) == CHANNEL_MAIN
|
||||
assert resolve_update_channel({"update": {}}) == CHANNEL_MAIN
|
||||
|
||||
def test_auto_and_unknown_mean_main(self):
|
||||
assert resolve_update_channel({"update": {"channel": "auto"}}) == CHANNEL_MAIN
|
||||
assert resolve_update_channel({"update": {"channel": "nightly"}}) == CHANNEL_MAIN
|
||||
|
||||
def test_case_and_whitespace_are_forgiven(self):
|
||||
assert resolve_update_channel({"update": {"channel": " Stable "}}) == CHANNEL_STABLE
|
||||
|
|
@ -78,20 +78,14 @@ class TestStableChannelActive:
|
|||
"""--branch means main-style behavior regardless of channel config."""
|
||||
assert _stable_channel_active(_Args(branch="bb/gui")) is False
|
||||
|
||||
def test_config_stable_activates(self, tmp_path):
|
||||
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}), \
|
||||
patch("hermes_cli.install_manifest.install_manifest_path",
|
||||
return_value=tmp_path / ".hermes-install.json"):
|
||||
def test_config_stable_activates(self):
|
||||
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}):
|
||||
assert _stable_channel_active(_Args()) is True
|
||||
|
||||
def test_default_config_stays_main(self, tmp_path):
|
||||
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}), \
|
||||
patch("hermes_cli.install_manifest.install_manifest_path",
|
||||
return_value=tmp_path / ".hermes-install.json"):
|
||||
def test_default_config_stays_main(self):
|
||||
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}):
|
||||
assert _stable_channel_active(_Args()) is False
|
||||
|
||||
def test_config_failure_defaults_to_main(self, tmp_path):
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")), \
|
||||
patch("hermes_cli.install_manifest.install_manifest_path",
|
||||
return_value=tmp_path / ".hermes-install.json"):
|
||||
def test_config_failure_defaults_to_main(self):
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
||||
assert _stable_channel_active(_Args()) is False
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"""Tests for ``hermes update --eject`` (hermes_cli/update_cmd.py::cmd_update_eject).
|
||||
|
||||
A bundled install is a resident desktop bundle: the agent runs out of the
|
||||
sealed app resources, and its manifest says ``installMode: bundled``. The
|
||||
eject downloads Hermes Setup from the website and launches it pinned to
|
||||
the bundle's exact build commit. The tests fake only the two hard process
|
||||
boundaries — the download and the installer launch — and run everything
|
||||
else for real.
|
||||
An embedded desktop install runs the agent out of the sealed app bundle:
|
||||
a gitless tree whose build stamp says ``distribution: desktop-app``. The
|
||||
eject is a full handoff to Hermes Setup. The tests fake only the two hard
|
||||
process boundaries — the download and the installer launch — and run
|
||||
everything else for real.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -13,35 +12,23 @@ import json
|
|||
import pytest
|
||||
|
||||
import hermes_cli.update_cmd as update_cmd
|
||||
from hermes_cli.install_manifest import (
|
||||
CHANNEL_STABLE,
|
||||
MODE_BUNDLED,
|
||||
MODE_SOURCE,
|
||||
read_install_manifest,
|
||||
write_install_manifest,
|
||||
)
|
||||
from hermes_cli.update_cmd import cmd_update_eject
|
||||
|
||||
COMMIT = "ab" * 20
|
||||
|
||||
|
||||
def _write_build_info(root, **overrides):
|
||||
info = {"commit": COMMIT, "tag": "v0.1.0", "distribution": "desktop-app"}
|
||||
info.update(overrides)
|
||||
(root / ".hermes_build_info.json").write_text(json.dumps(info))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle_repo(tmp_path, monkeypatch):
|
||||
"""The payload repo of a resident bundle: manifest + build info."""
|
||||
"""The payload repo of an embedded bundle: build info, no .git."""
|
||||
repo = tmp_path / "bundle" / "repo"
|
||||
repo.mkdir(parents=True)
|
||||
write_install_manifest(
|
||||
{
|
||||
"installMode": MODE_BUNDLED,
|
||||
"channel": CHANNEL_STABLE,
|
||||
"manageStyle": "adopted",
|
||||
"pinnedTag": "v0.1.0",
|
||||
},
|
||||
repo,
|
||||
)
|
||||
(repo / ".hermes_build_info.json").write_text(
|
||||
json.dumps({"commit": COMMIT, "tag": "v0.1.0"})
|
||||
)
|
||||
_write_build_info(repo)
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
|
|
@ -75,7 +62,7 @@ def fake_setup(monkeypatch):
|
|||
return calls
|
||||
|
||||
|
||||
class TestEjectResident:
|
||||
class TestEjectEmbedded:
|
||||
def test_eject_downloads_setup_and_pins_the_bundle_commit(
|
||||
self, bundle_repo, fake_setup, capsys
|
||||
):
|
||||
|
|
@ -87,7 +74,9 @@ class TestEjectResident:
|
|||
assert fake_setup["commit"] == COMMIT
|
||||
assert "Hermes-Setup.dmg" in fake_setup["url"]
|
||||
assert "hermes-assets.nousresearch.com" in fake_setup["url"]
|
||||
assert "Hermes Setup is running" in out
|
||||
# The handoff instructs the user to close the app: Setup replaces it.
|
||||
assert "full handoff" in out
|
||||
assert "Close the Hermes desktop app" in out
|
||||
|
||||
def test_eject_windows_uses_the_exe(self, bundle_repo, fake_setup, monkeypatch):
|
||||
monkeypatch.setattr(update_cmd.sys, "platform", "win32")
|
||||
|
|
@ -100,9 +89,7 @@ class TestEjectResident:
|
|||
assert "install.sh" in capsys.readouterr().out
|
||||
|
||||
def test_eject_refuses_without_a_valid_commit(self, bundle_repo, fake_setup, capsys):
|
||||
(bundle_repo / ".hermes_build_info.json").write_text(
|
||||
json.dumps({"commit": "not-a-sha"})
|
||||
)
|
||||
_write_build_info(bundle_repo, commit="not-a-sha")
|
||||
assert cmd_update_eject(_Args()) == 1
|
||||
assert "commit" in capsys.readouterr().out
|
||||
assert "commit" not in fake_setup # never launched
|
||||
|
|
@ -110,8 +97,6 @@ class TestEjectResident:
|
|||
def test_eject_skips_when_a_source_checkout_already_exists(
|
||||
self, bundle_repo, fake_setup, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
home = tmp_path / "hermes-home"
|
||||
target = home / "hermes-agent"
|
||||
(target / ".git").mkdir(parents=True)
|
||||
|
|
@ -127,32 +112,47 @@ class TestEjectResident:
|
|||
update_cmd, "_download_hermes_setup", lambda url, dest: False
|
||||
)
|
||||
assert cmd_update_eject(_Args()) == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "unchanged" in out
|
||||
# The bundle manifest is untouched: still bundled.
|
||||
assert read_install_manifest(bundle_repo).get("installMode") == MODE_BUNDLED
|
||||
assert "unchanged" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestEjectSourceManaged:
|
||||
def test_source_install_with_channel_switches_channel_only(
|
||||
class TestEjectOtherSealedTrees:
|
||||
def test_docker_tree_gets_the_docker_message_not_an_eject(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
repo = tmp_path / "src-checkout"
|
||||
repo = tmp_path / "docker-tree"
|
||||
repo.mkdir()
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
|
||||
_write_build_info(repo, distribution="docker")
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
|
||||
assert cmd_update_eject(_Args(channel="stable")) == 0
|
||||
manifest = read_install_manifest(repo)
|
||||
assert manifest["channel"] == "stable"
|
||||
assert manifest["installMode"] == MODE_SOURCE
|
||||
assert cmd_update_eject(_Args()) == 1
|
||||
assert "docker pull" in capsys.readouterr().out
|
||||
|
||||
def test_source_install_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
|
||||
|
||||
class TestEjectGitCheckout:
|
||||
def test_git_checkout_with_channel_switches_channel_only(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
repo = tmp_path / "src-checkout"
|
||||
repo.mkdir()
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
written = {}
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod, "set_config_value", lambda key, value, **kw: written.update({key: value})
|
||||
)
|
||||
|
||||
assert cmd_update_eject(_Args(channel="stable")) == 0
|
||||
assert written == {"update.channel": "stable"}
|
||||
assert "git-managed" in capsys.readouterr().out
|
||||
|
||||
def test_git_checkout_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
|
||||
repo = tmp_path / "src-checkout"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
|
|
|
|||
Loading…
Reference in New Issue