feat(update): install manifest, release channels, and eject
.hermes-install.json marks a checkout as source-managed or desktop-bundled and records its update channel. A missing file means source mode on the main channel, so no existing install changes. `hermes update` reads the manifest: - On a bundled install it refuses and points at the in-app updater. - On the stable channel it fast-forwards the checkout to the newest final release tag (vX.Y.Z, three-digit major cap so legacy CalVer tags never match) instead of origin/main. The ZIP fallback resolves the tag through the GitHub API because that path runs when git file I/O is broken. - `update.channel` in config.yaml overrides the channel for source installs. "auto" defers to the manifest. `hermes update --eject` is the exit from desktop management. On a bundled install it downloads Hermes Setup and launches it pinned to the exact commit the bundle was built from; the installer creates a normal source checkout at ~/.hermes/hermes-agent. Hermes Setup accepts the new `--pin-commit <sha>` argument for this flow. On a source-managed install, --eject with --channel only switches the channel. The "ejected" manageStyle is the permanent opt-out that stops future auto-adoption.
This commit is contained in:
parent
01aba5cf16
commit
694bd9ab2e
|
|
@ -165,6 +165,13 @@ docs/superpowers/*
|
|||
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
|
||||
/.install_method
|
||||
|
||||
# Install-mode manifest (bundled vs source, channel, pins) and the build stamp
|
||||
# staged into bundled payload trees. Both are Hermes-managed runtime state in
|
||||
# the checkout root, never a code change. After `hermes update --eject` grafts
|
||||
# git onto a bundled tree, these must not read as local edits.
|
||||
/.hermes-install.json
|
||||
/.hermes_build_info.json
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
|
|
|||
|
|
@ -98,7 +98,13 @@ pub async fn start_bootstrap(
|
|||
|
||||
let app_for_task = app.clone();
|
||||
let state_for_task = state.inner().clone();
|
||||
let args_for_task = args;
|
||||
let mut args_for_task = args;
|
||||
// A process-level `--pin-commit` (the resident-eject flow) beats both
|
||||
// the frontend's null and the build-time pin: this Setup binary is the
|
||||
// latest release, but the eject wants the ejecting app's own commit.
|
||||
if let Some(pin) = state.pin_commit.as_ref() {
|
||||
args_for_task.commit = Some(pin.clone());
|
||||
}
|
||||
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,37 @@ where
|
|||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Extract a runtime commit pin from `--pin-commit <sha>`. The one caller
|
||||
/// today is `hermes update --eject` on a resident desktop bundle: it
|
||||
/// launches Hermes Setup pinned to the exact commit its own release was
|
||||
/// built from, so the ejected source checkout matches the code the user
|
||||
/// was running. A runtime pin beats the build-time `BUILD_PIN_COMMIT`
|
||||
/// because the downloaded Hermes-Setup binary is the LATEST build — its
|
||||
/// baked pin names a newer release than the app doing the eject. Only a
|
||||
/// full 40-char hex sha is accepted; anything else is ignored and the
|
||||
/// build-time pin applies.
|
||||
pub fn pin_commit_from_args<I, S>(args: I) -> Option<String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut take_next = false;
|
||||
for a in args {
|
||||
let a = a.as_ref();
|
||||
if take_next {
|
||||
let sha = a.trim().to_ascii_lowercase();
|
||||
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Some(sha);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if a == "--pin-commit" {
|
||||
take_next = true;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
|
|
@ -74,13 +105,17 @@ pub struct AppState {
|
|||
/// How this process was launched (install vs update). Immutable for the
|
||||
/// lifetime of the process; read by the `get_mode` command.
|
||||
pub mode: AppMode,
|
||||
/// Runtime commit pin from `--pin-commit` (the resident-eject flow).
|
||||
/// Overrides `BUILD_PIN_COMMIT` for every bootstrap this process runs.
|
||||
pub pin_commit: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(mode: AppMode) -> Self {
|
||||
fn new(mode: AppMode, pin_commit: Option<String>) -> Self {
|
||||
Self {
|
||||
bootstrap: Mutex::new(None),
|
||||
mode,
|
||||
pin_commit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,14 +138,15 @@ pub fn run() {
|
|||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
let pin_commit = pin_commit_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, ?pin_commit, "Hermes installer starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.manage(Arc::new(AppState::new(mode, pin_commit.clone())))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
|
|
@ -129,7 +165,9 @@ pub fn run() {
|
|||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
// `--pin-commit` opts out too: that launch IS an install request
|
||||
// (the resident-eject flow), never a launcher double-click.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup && pin_commit.is_none() {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
|
|
@ -187,7 +225,7 @@ pub fn run() {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
use super::{force_setup_from_args, pin_commit_from_args, AppMode};
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
|
|
@ -229,4 +267,30 @@ mod tests {
|
|||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_commit_takes_only_a_full_hex_sha() {
|
||||
let sha = "a".repeat(40);
|
||||
assert_eq!(
|
||||
pin_commit_from_args(["--pin-commit", &sha]),
|
||||
Some(sha.clone())
|
||||
);
|
||||
// Uppercase input normalizes to lowercase.
|
||||
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
assert_eq!(
|
||||
pin_commit_from_args(["--pin-commit", upper]),
|
||||
Some(upper.to_ascii_lowercase())
|
||||
);
|
||||
// Tags, branches, short shas, and missing values are all rejected —
|
||||
// the build-time pin applies instead.
|
||||
assert_eq!(pin_commit_from_args(["--pin-commit", "v1.2.3"]), None);
|
||||
assert_eq!(pin_commit_from_args(["--pin-commit", "deadbeef"]), None);
|
||||
assert_eq!(pin_commit_from_args(["--pin-commit"]), None);
|
||||
assert_eq!(pin_commit_from_args(Vec::<String>::new()), None);
|
||||
// Position-independent among other flags.
|
||||
assert_eq!(
|
||||
pin_commit_from_args(["--update", "--pin-commit", &sha, "--foo"]),
|
||||
Some(sha)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2456,6 +2456,19 @@ DEFAULT_CONFIG = {
|
|||
"backup_count": 3, # Number of rotated backup files to keep
|
||||
},
|
||||
|
||||
# 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).
|
||||
# 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.
|
||||
"channel": "auto",
|
||||
},
|
||||
|
||||
# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
|
||||
# curated model lists for OpenRouter and Nous Portal from this URL,
|
||||
# falling back to the in-repo snapshot on network failure. Lets us
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
"""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."
|
||||
)
|
||||
|
|
@ -9183,6 +9183,27 @@ def cmd_update(args):
|
|||
print(recommended_update_command_for_method(install_method))
|
||||
sys.exit(1)
|
||||
|
||||
# --eject runs BEFORE the bundled-install refusal below. The eject
|
||||
# operation is the one update operation that must work on a bundled
|
||||
# install. It is the exit from desktop management. On source installs
|
||||
# it only sets the channel or does nothing.
|
||||
if getattr(args, "eject", False):
|
||||
from hermes_cli.update_cmd import cmd_update_eject
|
||||
|
||||
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
|
||||
|
||||
if is_bundled_install(PROJECT_ROOT):
|
||||
print(format_bundled_update_message())
|
||||
sys.exit(1)
|
||||
|
||||
if getattr(args, "check", False):
|
||||
# --check honors --branch so the "any new commits?" answer matches
|
||||
# what a subsequent `hermes update --branch=<x>` would actually pull.
|
||||
|
|
|
|||
|
|
@ -73,4 +73,29 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
|
|||
default=False,
|
||||
help="Windows: mutate the venv even while other processes are running from its interpreter (desktop backend, gateway, terminals). Those processes keep native .pyd files locked, so the dependency sync will likely fail partway and strand the install half-updated. Use only if you know the detected holders are false positives.",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--eject",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Take control of updates for a desktop-bundled install. This "
|
||||
"option marks the checkout as source-managed (git updates with "
|
||||
"`hermes update`) and fetches the full git history. The desktop "
|
||||
"app continues to update itself, but it no longer touches the "
|
||||
"agent checkout. This option has no effect on installs that are "
|
||||
"already source-managed."
|
||||
),
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--channel",
|
||||
default=None,
|
||||
choices=("stable", "main"),
|
||||
metavar="CHANNEL",
|
||||
help=(
|
||||
"With --eject: the releases that the ejected install tracks. Use "
|
||||
"'stable' for tagged releases or 'main' for the git main branch. "
|
||||
"The desktop cadence before the eject is 'stable'. The default "
|
||||
"is 'main'."
|
||||
),
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import hashlib
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
|
|
@ -154,6 +155,154 @@ _UPDATE_CRITICAL_FILES = (
|
|||
"hermes_constants.py",
|
||||
)
|
||||
|
||||
# Release tags have the form v1.2.3. A tag can have a pre-release suffix.
|
||||
# The stable channel ignores tags with a suffix. Stable means final releases only.
|
||||
# The major component is capped at three digits. The historical CalVer tags
|
||||
# (for example v2026.7.20) use a four-digit year, and a numeric sort would
|
||||
# rank them above every SemVer release. This matches _SEMVER_TAG_RE in
|
||||
# scripts/write_install_stamp.py.
|
||||
_RELEASE_TAG_RE = re.compile(r"^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
def _parse_release_tag(tag: str):
|
||||
"""Parse ``vX.Y.Z`` into a sortable (X, Y, Z) tuple, or return None.
|
||||
|
||||
Tags with a pre-release or build suffix (``v1.2.3-rc1``) return None.
|
||||
Tags that do not have the shape of a final release also return None.
|
||||
The stable channel only moves between final releases.
|
||||
"""
|
||||
m = _RELEASE_TAG_RE.match(tag.strip())
|
||||
if not m:
|
||||
return None
|
||||
return tuple(int(g) for g in m.groups())
|
||||
|
||||
|
||||
def _latest_release_tag_from_ls_remote(output: str):
|
||||
"""Select the newest final-release tag from ``git ls-remote --tags`` output.
|
||||
|
||||
Returns ``(tag, sha)`` or ``(None, None)``. Peeled entries (``^{}``) have
|
||||
priority over the tag-object SHA. Thus annotated tags and lightweight tags
|
||||
both give the commit SHA.
|
||||
"""
|
||||
best = None # (version_tuple, tag)
|
||||
shas = {} # tag -> commit sha (peeled wins)
|
||||
for line in output.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
sha, ref = parts
|
||||
if not ref.startswith("refs/tags/"):
|
||||
continue
|
||||
name = ref[len("refs/tags/"):]
|
||||
peeled = name.endswith("^{}")
|
||||
if peeled:
|
||||
name = name[:-3]
|
||||
version = _parse_release_tag(name)
|
||||
if version is None:
|
||||
continue
|
||||
if peeled or name not in shas:
|
||||
shas[name] = sha.strip()
|
||||
if best is None or version > best[0]:
|
||||
best = (version, name)
|
||||
if best is None:
|
||||
return None, None
|
||||
tag = best[1]
|
||||
return tag, shas.get(tag)
|
||||
|
||||
|
||||
def _resolve_latest_release_tag(git_cmd, cwd):
|
||||
"""Ask origin for the newest final release tag. Returns (tag, sha) or (None, None)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
git_cmd + ["ls-remote", "--tags", "origin", "v*"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Could not list release tags from origin: %s", exc)
|
||||
return None, None
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"git ls-remote --tags failed: %s",
|
||||
(result.stderr or "").strip().splitlines()[:1],
|
||||
)
|
||||
return None, None
|
||||
return _latest_release_tag_from_ls_remote(result.stdout)
|
||||
|
||||
|
||||
def _stable_channel_active(args) -> bool:
|
||||
"""Return True when this update must track tagged releases, not a branch.
|
||||
|
||||
``args`` is the update argparse namespace, or None when the caller has no
|
||||
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).
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
except Exception as exc:
|
||||
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _github_latest_release_tag():
|
||||
"""Resolve the newest final-release tag with the GitHub API (no git necessary).
|
||||
|
||||
The ZIP-fallback path uses this function. That path exists because git
|
||||
file I/O is broken. The function tries /releases/latest first, because that
|
||||
endpoint obeys the draft and prerelease curation. If that fails, it lists
|
||||
the tags and selects the maximum final release.
|
||||
Returns the tag name or None.
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
def _get_json(url):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "hermes-update"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
base = "https://api.github.com/repos/NousResearch/hermes-agent"
|
||||
try:
|
||||
data = _get_json(f"{base}/releases/latest")
|
||||
tag = data.get("tag_name")
|
||||
if isinstance(tag, str) and _parse_release_tag(tag) is not None:
|
||||
return tag
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
logger.debug("GitHub /releases/latest failed: %s", exc)
|
||||
try:
|
||||
data = _get_json(f"{base}/tags?per_page=100")
|
||||
candidates = [
|
||||
(v, t["name"])
|
||||
for t in data
|
||||
if isinstance(t, dict) and isinstance(t.get("name"), str)
|
||||
and (v := _parse_release_tag(t["name"])) is not None
|
||||
]
|
||||
if candidates:
|
||||
return max(candidates)[1]
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
logger.warning("Could not resolve latest release from GitHub API: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _capture_head_sha(git_cmd, cwd) -> str | None:
|
||||
"""Return the current HEAD SHA, or None if it can't be resolved."""
|
||||
try:
|
||||
|
|
@ -802,8 +951,21 @@ def _update_via_zip(args):
|
|||
f"--branch {branch}`, or update against main with `hermes update`."
|
||||
)
|
||||
_m().sys.exit(1)
|
||||
|
||||
# Stable channel: pull the archive of the release tag, not main. The ZIP
|
||||
# path runs when git file I/O is broken. Thus resolve the tag with the
|
||||
# GitHub API, not with git. No git invocation is necessary.
|
||||
zip_ref = f"refs/heads/{branch}"
|
||||
if _stable_channel_active(args):
|
||||
tag = _github_latest_release_tag()
|
||||
if tag is None:
|
||||
print("✗ Hermes cannot resolve the latest release from the GitHub API.")
|
||||
print(" Switch channels with: hermes config set update.channel main")
|
||||
_m().sys.exit(1)
|
||||
print(f"→ Update channel: stable. Hermes downloads release {tag}.")
|
||||
zip_ref = f"refs/tags/{tag}"
|
||||
zip_url = (
|
||||
f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
|
||||
f"https://github.com/NousResearch/hermes-agent/archive/{zip_ref}.zip"
|
||||
)
|
||||
|
||||
print("→ Downloading latest version...")
|
||||
|
|
@ -2284,6 +2446,40 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
|||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
|
||||
# Stable channel: if the caller did not ask for a branch, the question is
|
||||
# "is there a newer tagged release?". The question is not "are there new
|
||||
# commits on main?". Compare against the newest release tag and return.
|
||||
if not branch_explicit:
|
||||
if _stable_channel_active(None):
|
||||
print("→ Update channel: stable (tagged releases)")
|
||||
tag, tag_sha = _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT)
|
||||
if tag is None:
|
||||
print("✗ No release tags found on origin. A check of the stable channel is not possible.")
|
||||
print(" Switch channels with: hermes config set update.channel main")
|
||||
sys.exit(1)
|
||||
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||||
# Newer releases possibly do not exist locally yet. "At the tag"
|
||||
# is a SHA comparison. The merge-base check tells us whether HEAD
|
||||
# contains the tag (HEAD is ahead of the release or at the release).
|
||||
at_or_past_tag = False
|
||||
if head_sha and tag_sha:
|
||||
if head_sha == tag_sha:
|
||||
at_or_past_tag = True
|
||||
else:
|
||||
contained = subprocess.run(
|
||||
git_cmd + ["merge-base", "--is-ancestor", tag_sha, "HEAD"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
at_or_past_tag = contained.returncode == 0
|
||||
if at_or_past_tag:
|
||||
print(f"✓ Up to date with the latest release ({tag}).")
|
||||
else:
|
||||
print(f"→ New release available: {tag}")
|
||||
print(" Run `hermes update` to install it.")
|
||||
return
|
||||
|
||||
# Fetch only the branch we compare against; prefer upstream as the canonical
|
||||
# reference. A bare `git fetch <remote>` pulls every ref, and this repo has
|
||||
# thousands of auto-generated branches, so scope the fetch to <branch>.
|
||||
|
|
@ -3827,6 +4023,218 @@ def _normalize_managed_eol(git_cmd, repo_root):
|
|||
# Never let line-ending cleanup block an update.
|
||||
pass
|
||||
|
||||
|
||||
def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
|
||||
"""Eject a resident bundle: hand the install to Hermes Setup.
|
||||
|
||||
The resident 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.
|
||||
|
||||
Returns a process exit code.
|
||||
"""
|
||||
if sys.platform not in ("darwin", "win32"):
|
||||
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"):
|
||||
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.
|
||||
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):
|
||||
print("\u2717 An eject is not possible. The bundle's build info has no valid commit.")
|
||||
print(" Reinstall from source instead:")
|
||||
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
|
||||
return 1
|
||||
|
||||
setup_name = "Hermes-Setup.dmg" if sys.platform == "darwin" else "Hermes-Setup.exe"
|
||||
setup_url = f"https://hermes-assets.nousresearch.com/{setup_name}"
|
||||
|
||||
print("\u2695 Hermes ejects this install from the sealed app bundle...")
|
||||
print(f"\u2192 Hermes downloads Hermes Setup from {setup_url} ...")
|
||||
|
||||
import tempfile
|
||||
|
||||
scratch = Path(tempfile.mkdtemp(prefix="hermes-eject-"))
|
||||
setup_path = scratch / setup_name
|
||||
if not _download_hermes_setup(setup_url, setup_path):
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
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]})...")
|
||||
ok = _launch_hermes_setup(setup_path, scratch, commit)
|
||||
if not ok:
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
print("\u2717 Hermes could not start the installer. The install is unchanged.")
|
||||
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(" \u2022 After the install, update with: hermes update")
|
||||
return 0
|
||||
|
||||
|
||||
def _read_json_or_none(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _download_hermes_setup(url: str, dest: Path) -> bool:
|
||||
"""Download the installer to ``dest``. Returns False on any failure."""
|
||||
import urllib.request
|
||||
|
||||
# The asset CDN rejects urllib's default Python-urllib/3.x agent
|
||||
# with 403; identify as Hermes instead.
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "hermes-agent-eject"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as resp, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(resp, out)
|
||||
return True
|
||||
except OSError as exc:
|
||||
print(f" {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def _launch_hermes_setup(setup_path: Path, scratch: Path, commit: str) -> bool:
|
||||
"""Start the downloaded Hermes Setup detached, pinned to ``commit``.
|
||||
|
||||
macOS: mount the dmg, copy the .app out to the scratch dir (so the
|
||||
mount can go away), detach, and open the copy. ``open`` passes args
|
||||
after ``--args`` to the app process. Windows: run the exe directly.
|
||||
Returns False when any step fails; never raises.
|
||||
"""
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
mount = scratch / "mnt"
|
||||
mount.mkdir()
|
||||
attach = subprocess.run(
|
||||
["hdiutil", "attach", str(setup_path), "-mountpoint", str(mount),
|
||||
"-nobrowse", "-quiet"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
if attach.returncode != 0:
|
||||
print(f" hdiutil attach failed: {(attach.stderr or '').strip()}")
|
||||
return False
|
||||
try:
|
||||
apps = sorted(mount.glob("*.app"))
|
||||
if not apps:
|
||||
print(" The mounted image has no .app.")
|
||||
return False
|
||||
app_copy = scratch / apps[0].name
|
||||
shutil.copytree(apps[0], app_copy, symlinks=True)
|
||||
finally:
|
||||
subprocess.run(
|
||||
["hdiutil", "detach", str(mount), "-quiet"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
launch = subprocess.run(
|
||||
["open", "-n", str(app_copy), "--args", "--pin-commit", commit],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
if launch.returncode != 0:
|
||||
print(f" open failed: {(launch.stderr or '').strip()}")
|
||||
return launch.returncode == 0
|
||||
# Windows: the exe is the app. Detach so the eject command returns.
|
||||
subprocess.Popen(
|
||||
[str(setup_path), "--pin-commit", commit],
|
||||
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
close_fds=True,
|
||||
)
|
||||
return True
|
||||
except OSError as exc:
|
||||
print(f" {exc}")
|
||||
return False
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Returns a process exit code.
|
||||
"""
|
||||
from hermes_cli.install_manifest import (
|
||||
CHANNEL_MAIN,
|
||||
CHANNEL_STABLE,
|
||||
MODE_SOURCE,
|
||||
STYLE_EJECTED,
|
||||
install_manifest_path,
|
||||
read_install_manifest,
|
||||
write_install_manifest,
|
||||
)
|
||||
|
||||
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'.")
|
||||
return 1
|
||||
|
||||
if manifest.get("installMode") != "bundled":
|
||||
# The install is already source-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}'.")
|
||||
else:
|
||||
print("✓ Nothing to eject. This install is already source-managed.")
|
||||
print(" (Only desktop-bundled 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")
|
||||
return 1
|
||||
|
||||
# Hermes Setup writes the ejected checkout's own manifest, 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(" After the install, set it with: hermes update --eject --channel " + channel)
|
||||
|
||||
return _eject_resident_bundle(project_root, pinned_tag)
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""Body of ``cmd_update`` — kept separate so the wrapper can always
|
||||
restore stdio even on ``sys.exit``."""
|
||||
|
|
@ -4030,9 +4438,30 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
# against.
|
||||
branch = _m()._resolve_update_branch(args)
|
||||
|
||||
# target_ref is the reference that we count against, fast-forward to,
|
||||
# and reset to. On the main channel it is origin/<branch>. That is the
|
||||
# historical behavior. On the stable channel it is the commit of the
|
||||
# newest release tag. The current branch pointer fast-forwards to the
|
||||
# release. Thus the checkout keeps its branch shape (no detached HEAD).
|
||||
# The next stable update then fast-forward merges to the next tag.
|
||||
target_ref = f"origin/{branch}"
|
||||
stable_tag = None
|
||||
if _stable_channel_active(args):
|
||||
print("→ Update channel: stable (tagged releases)")
|
||||
stable_tag, _stable_tag_sha = _resolve_latest_release_tag(
|
||||
git_cmd, _m().PROJECT_ROOT
|
||||
)
|
||||
if stable_tag is None:
|
||||
print("✗ No release tags found on origin. An update on the stable channel is not possible.")
|
||||
print(" Switch channels with: hermes config set update.channel main")
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
sys.exit(1)
|
||||
print(f"→ Latest release: {stable_tag}")
|
||||
|
||||
print("→ Fetching updates...")
|
||||
fetch_target = ["tag", stable_tag] if stable_tag else [branch]
|
||||
fetch_result = subprocess.run(
|
||||
git_cmd + ["fetch", "origin", branch],
|
||||
git_cmd + ["fetch", "origin", *fetch_target],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
|
|
@ -4068,8 +4497,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
# to the target. When the target is "main" this is the historical
|
||||
# "always update against main" behavior; for any other target it's
|
||||
# the same thing — get HEAD onto the requested branch first, then
|
||||
# fast-forward.
|
||||
if current_branch != branch:
|
||||
# fast-forward. On the stable channel we do NOT switch branches. The
|
||||
# branch of the checkout fast-forwards (or resets) to the commit of
|
||||
# the release tag.
|
||||
if stable_tag is not None:
|
||||
target_ref = stable_tag
|
||||
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
|
||||
elif current_branch != branch:
|
||||
label = (
|
||||
"detached HEAD"
|
||||
if current_branch == "HEAD"
|
||||
|
|
@ -4121,7 +4555,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
|
||||
# Check if there are updates
|
||||
result = subprocess.run(
|
||||
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||
git_cmd + ["rev-list", f"HEAD..{target_ref}", "--count"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
|
|
@ -4133,7 +4567,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
_invalidate_update_cache()
|
||||
|
||||
# Even if origin is up to date, the fork may be behind upstream
|
||||
if is_fork and branch == "main":
|
||||
# (main channel only, because a stable checkout tracks tags, not main).
|
||||
if is_fork and branch == "main" and stable_tag is None:
|
||||
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)
|
||||
|
||||
# Restore stash and switch back to original branch if we moved
|
||||
|
|
@ -4249,7 +4684,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
# `pull --ff-only origin <branch>` given the fresh tracking ref;
|
||||
# the divergence fallback below is unchanged.
|
||||
pull_result = subprocess.run(
|
||||
git_cmd + ["merge", "--ff-only", f"origin/{branch}"],
|
||||
git_cmd + ["merge", "--ff-only", target_ref],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
|
|
@ -4262,17 +4697,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
|
||||
)
|
||||
reset_result = subprocess.run(
|
||||
git_cmd + ["reset", "--hard", f"origin/{branch}"],
|
||||
git_cmd + ["reset", "--hard", target_ref],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
if reset_result.returncode != 0:
|
||||
print(f"✗ Failed to reset to origin/{branch}.")
|
||||
print(f"✗ Failed to reset to {target_ref}.")
|
||||
if reset_result.stderr.strip():
|
||||
print(f" {reset_result.stderr.strip()}")
|
||||
print(
|
||||
f" Try manually: git fetch origin && git reset --hard origin/{branch}"
|
||||
f" Try manually: git fetch origin && git reset --hard {target_ref}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -1029,6 +1029,11 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
|||
"prompt_caching": "agent",
|
||||
"goals": "agent",
|
||||
"updates": "general",
|
||||
# `update.channel` is the only schema-surfaced field under `update` (the
|
||||
# bundled-install release-channel selector) — fold it into general next
|
||||
# to the sibling `updates` section rather than spawning a one-field
|
||||
# orphan category.
|
||||
"update": "general",
|
||||
# `onboarding.profile_build` is the only schema-surfaced onboarding field
|
||||
# (`onboarding.seen` is an internal latch dict, not a user setting), so fold
|
||||
# it into the agent tab rather than spawning a one-field orphan category.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
"""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,97 @@
|
|||
"""Tests for the stable update channel (tag-tracking) in hermes_cli/update_cmd.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.update_cmd import (
|
||||
_latest_release_tag_from_ls_remote,
|
||||
_parse_release_tag,
|
||||
_stable_channel_active,
|
||||
)
|
||||
|
||||
|
||||
class TestParseReleaseTag:
|
||||
def test_final_releases_parse(self):
|
||||
assert _parse_release_tag("v0.17.0") == (0, 17, 0)
|
||||
assert _parse_release_tag("v10.2.33") == (10, 2, 33)
|
||||
assert _parse_release_tag(" v1.2.3 ") == (1, 2, 3)
|
||||
|
||||
def test_prereleases_and_garbage_rejected(self):
|
||||
for tag in ("v1.2.3-rc1", "v1.2.3-beta.1", "v1.2", "1.2.3", "release-1", "vv1.2.3", ""):
|
||||
assert _parse_release_tag(tag) is None, tag
|
||||
|
||||
def test_calver_tags_rejected(self):
|
||||
"""Historical CalVer tags (v2026.7.20) must not win a numeric sort.
|
||||
|
||||
The major component is capped at three digits, the same rule as
|
||||
_SEMVER_TAG_RE in scripts/write_install_stamp.py and
|
||||
latestReleaseFromLsRemote in apps/desktop. A four-digit year would
|
||||
rank above every SemVer release forever.
|
||||
"""
|
||||
assert _parse_release_tag("v2026.7.20") is None
|
||||
assert _parse_release_tag("v1000.0.0") is None
|
||||
assert _parse_release_tag("v999.0.0") == (999, 0, 0)
|
||||
|
||||
def test_numeric_ordering_not_lexicographic(self):
|
||||
"""v0.10.0 must sort above v0.9.0 — the whole point of tuple parsing."""
|
||||
newer, older = _parse_release_tag("v0.10.0"), _parse_release_tag("v0.9.0")
|
||||
assert newer is not None and older is not None
|
||||
assert newer > older
|
||||
|
||||
|
||||
class TestLatestReleaseTagFromLsRemote:
|
||||
def test_picks_newest_final_release(self):
|
||||
output = (
|
||||
"aaa1\trefs/tags/v0.9.0\n"
|
||||
"bbb2\trefs/tags/v0.10.0\n"
|
||||
"ccc3\trefs/tags/v0.10.1-rc1\n"
|
||||
"ddd4\trefs/tags/some-other-tag\n"
|
||||
)
|
||||
tag, sha = _latest_release_tag_from_ls_remote(output)
|
||||
assert tag == "v0.10.0"
|
||||
assert sha == "bbb2"
|
||||
|
||||
def test_peeled_sha_wins_for_annotated_tags(self):
|
||||
output = (
|
||||
"tagobj\trefs/tags/v1.0.0\n"
|
||||
"commitsha\trefs/tags/v1.0.0^{}\n"
|
||||
)
|
||||
tag, sha = _latest_release_tag_from_ls_remote(output)
|
||||
assert tag == "v1.0.0"
|
||||
assert sha == "commitsha"
|
||||
|
||||
def test_no_release_tags(self):
|
||||
assert _latest_release_tag_from_ls_remote("aaa\trefs/tags/nightly\n") == (None, None)
|
||||
assert _latest_release_tag_from_ls_remote("") == (None, None)
|
||||
|
||||
def test_malformed_lines_ignored(self):
|
||||
output = "garbage line no tab\naaa\trefs/heads/main\nbbb\trefs/tags/v2.0.0\n"
|
||||
assert _latest_release_tag_from_ls_remote(output) == ("v2.0.0", "bbb")
|
||||
|
||||
|
||||
class _Args:
|
||||
def __init__(self, branch=None):
|
||||
self.branch = branch
|
||||
|
||||
|
||||
class TestStableChannelActive:
|
||||
def test_explicit_branch_always_wins(self):
|
||||
"""--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"):
|
||||
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"):
|
||||
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"):
|
||||
assert _stable_channel_active(_Args()) is False
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle_repo(tmp_path, monkeypatch):
|
||||
"""The payload repo of a resident bundle: manifest + build info."""
|
||||
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"})
|
||||
)
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
return repo
|
||||
|
||||
|
||||
class _Args:
|
||||
def __init__(self, channel=None):
|
||||
self.eject = True
|
||||
self.channel = channel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_setup(monkeypatch):
|
||||
"""Fake the download + launch boundary; record what eject asked for."""
|
||||
calls = {}
|
||||
|
||||
def fake_download(url, dest):
|
||||
calls["url"] = url
|
||||
dest.write_bytes(b"fake-installer")
|
||||
return True
|
||||
|
||||
def fake_launch(setup_path, scratch, commit):
|
||||
calls["setup_path"] = setup_path
|
||||
calls["commit"] = commit
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(update_cmd, "_download_hermes_setup", fake_download)
|
||||
monkeypatch.setattr(update_cmd, "_launch_hermes_setup", fake_launch)
|
||||
monkeypatch.setattr(update_cmd.sys, "platform", "darwin")
|
||||
return calls
|
||||
|
||||
|
||||
class TestEjectResident:
|
||||
def test_eject_downloads_setup_and_pins_the_bundle_commit(
|
||||
self, bundle_repo, fake_setup, capsys
|
||||
):
|
||||
rc = cmd_update_eject(_Args())
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert rc == 0
|
||||
# The pin is the bundle's own commit — never the tag, never HEAD.
|
||||
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
|
||||
|
||||
def test_eject_windows_uses_the_exe(self, bundle_repo, fake_setup, monkeypatch):
|
||||
monkeypatch.setattr(update_cmd.sys, "platform", "win32")
|
||||
assert cmd_update_eject(_Args()) == 0
|
||||
assert fake_setup["url"].endswith("Hermes-Setup.exe")
|
||||
|
||||
def test_eject_refuses_unsupported_platforms(self, bundle_repo, monkeypatch, capsys):
|
||||
monkeypatch.setattr(update_cmd.sys, "platform", "linux")
|
||||
assert cmd_update_eject(_Args()) == 1
|
||||
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"})
|
||||
)
|
||||
assert cmd_update_eject(_Args()) == 1
|
||||
assert "commit" in capsys.readouterr().out
|
||||
assert "commit" not in fake_setup # never launched
|
||||
|
||||
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)
|
||||
monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: home)
|
||||
|
||||
assert cmd_update_eject(_Args()) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out
|
||||
assert "url" not in fake_setup # no download
|
||||
|
||||
def test_failed_download_aborts_cleanly(self, bundle_repo, fake_setup, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
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
|
||||
|
||||
|
||||
class TestEjectSourceManaged:
|
||||
def test_source_install_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)
|
||||
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
|
||||
|
||||
def test_source_install_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
|
||||
repo = tmp_path / "src-checkout"
|
||||
repo.mkdir()
|
||||
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
|
||||
import hermes_cli.main as hermes_main
|
||||
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
|
||||
assert cmd_update_eject(_Args()) == 0
|
||||
assert "Nothing to eject" in capsys.readouterr().out
|
||||
Loading…
Reference in New Issue