mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
* feat(profile): shareable profile distributions (pack/install/update/info) Closes #20456. Turns a profile into a portable, versioned artifact. Packs SOUL.md, config, skills, cron, and an env-var manifest into a tar.gz that others can install from a local path, URL, or git repo. Updates re-pull the distribution while preserving user data (memories, sessions, auth.json, .env) and the user's config.yaml overrides. New subcommands (under hermes profile, no parallel tree): hermes profile pack <name> [-o FILE] hermes profile install <source> [--name N] [--alias] [--force] [-y] hermes profile update <name> [--force-config] [-y] hermes profile info <name> Manifest (distribution.yaml at the profile root): name, version, hermes_requires, author, env_requires, distribution_owned. Security: - Installer shows manifest + env-var requirements before mutating disk; confirmation required unless -y. - auth.json and .env are never packed (same exclude set as profile export). - Cron jobs are packed but NOT auto-scheduled — user is pointed at 'hermes -p <name> cron list' to review. - Archive extraction rejects path traversal (../ members). - Alias creation is opt-in via --alias. Update semantics: - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest): replaced from the new archive. - config.yaml: preserved by default; --force-config to overwrite. - User-owned paths (memories/, sessions/, auth.json, .env, state.db*, logs/, workspace/, plans/, home/, *_cache/, local/): never touched. Version pin: hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated as >=). Install fails with a clear error when the running Hermes version doesn't satisfy the spec. Sources supported by 'install': - Local .tar.gz / .tgz archive - Local directory - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep) - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://) Tests: 43 new unit tests (manifest parsing, version checks, env template, pack/install/update round-trip, config-preservation, security). E2E validated via real CLI invocations against an isolated HERMES_HOME covering pack, install with confirmation, update preservation, update --force-config, decline-preview, duplicate-install rejection, and version-requirement rejection. * refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack Scope-cut on top of the original distribution PR: a profile distribution is now exclusively a git repository (or a local directory during development). The tar.gz / HTTP archive transports and the matching `hermes profile pack` subcommand have been removed. Why: * GitHub tags, branches, and commits are already the right versioning primitive. Tag pushes do for us what 'pack + upload' did. * `hermes profile export` / `import` already cover local backup and restore; they are not a distribution format and stay untouched. * One transport means one install/update code path, one doc page, and one mental model. The extra source types doubled the surface for no real user win — GitHub auto-attaches release tarballs, and `git bundle` / `git clone --mirror` cover the airgap case. Changes: * hermes_cli/profile_distribution.py — removed pack_profile, _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots, _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The new _stage_source has two arms: git URL → clone, local directory → use in place. * hermes_cli/main.py — removed the 'pack' subparser and action handler. Install help text updated to match the reduced source list. * tests/hermes_cli/test_profile_distribution.py — rewritten around a local-directory staging fixture. The install/update/describe suites now build a distribution tree on disk directly and install from it, which is what a real git clone produces after .git is stripped. Dropped TestPack, TestFindDistRoot, and the tar-specific security test. New tests cover _looks_like_git_url, env_example emission, hermes_requires enforcement, and 'installer does not import credentials if an author mistakenly leaks them in the staging tree'. * website/docs/reference/profile-commands.md — 'Distribution commands' section rewritten around git. Added a 'Publishing a distribution' section. export/import stay documented as local backup/restore. * website/docs/reference/cli-commands.md — dropped 'pack' from the profile subcommand table. * website/package.json — 'lint:diagrams' now passes --exclude-code-blocks to ascii-guard. Without it, markdown tables and box-drawing diagrams inside fenced code blocks were being misidentified as malformed ASCII boxes, blocking the PR's docs-site-checks CI with 8 false-positive errors. Validation: * Targeted suite: tests/hermes_cli/test_profile_distribution.py — 56/56 pass (down from 43 — reorganized to cover the new local-dir paths). * Regression: test_profiles.py + test_profile_export_credentials.py 102/102 still pass. export/import behaviour unchanged. * Docs lint: ascii-guard lint --exclude-code-blocks docs returns 0 errors (was 8 on the PR before the flag bump). * E2E: ran the real `hermes profile install`/`info` against a local staging dir under an isolated HERMES_HOME — install writes SOUL.md + skills to the target profile, info reads the manifest back, a bogus source produces a clear error, and `hermes profile pack` is now rejected by argparse as expected. * feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview Polish pass on top of the git-only scope cut. Five additions, all small, wiring into existing commands rather than adding new surface. 1. `installed_at` timestamp on the manifest * Stamped automatically inside plan_install() on both fresh install and update — ISO-8601 UTC, seconds resolution. * Surfaced in `hermes profile info` as `Installed: <ts>`. * Lets users tell "installed 6 months ago, needs update" from "installed yesterday" without guessing from file mtimes. 2. `hermes profile list` grows a `Distribution` column * Plain profiles: "—" * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`) * ProfileInfo gains three optional fields — distribution_name, distribution_version, distribution_source — populated by a new _read_distribution_meta() helper that swallows manifest read errors so a broken distribution.yaml in one profile can't break `list` for the others. 3. `hermes profile show` and `hermes profile delete` surface distribution provenance * show: `Distribution: name@version` + `Installed from: <source>` plus a pointer to `hermes profile info <name>` for the full manifest. * delete: same lines in the pre-confirmation preview, so a user deleting "telemetry" can see it came from `github.com/kyle/telemetry-distribution` before they type `telemetry` to confirm. No change to the confirmation gate itself — deletion semantics are identical to plain profiles. 4. Install preview checks env vars against the current environment * Replaces the "Env vars you'll need to set:" header with a simpler "Env vars:" block. * Each required var is labeled: - `✓ set` — already in `os.environ` OR present as a key in the target profile's existing .env (update case). - `needs setting` — required but not found in either place. - `—` — optional. * Mirrors pip's "Requirement already satisfied" UX: no unnecessary nagging about keys the user already has configured. 5. Docs: private distributions * New "Private distributions" section in website/docs/reference/profile-commands.md explaining that we shell out to the user's `git` binary, so SSH keys / credential helpers / GitHub CLI stored creds all work transparently. One paragraph, two examples. * `hermes profile info` section updated to mention `Installed:`. Module-level hoist: * `from datetime import datetime, timezone` was previously lazy-imported inside plan_install(). Hoisted to module scope so tests can monkeypatch `hermes_cli.profile_distribution.datetime` to freeze time. Tests (+7): * TestInstalledAtStamp.test_install_stamps_installed_at — format check (4-digit year, 'T', +00:00 suffix). * TestInstalledAtStamp.test_update_refreshes_installed_at — freezes datetime.now() to 2099-01-01 and confirms update writes a new stamp. * TestProfileInfoDistribution.test_installed_distribution_shows_in_list — ProfileInfo.distribution_{name,version,source} populated after install. * TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields — plain profiles have None. * TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list — broken distribution.yaml in one profile doesn't break list_profiles(). Validation: * 163/163 tests pass (56 distribution + 102 profile regression + 5 new from this commit — up from 158). * docs-lint: 0 errors. * E2E verified: install preview shows ✓/needs-setting per env var, `profile list` shows Distribution column, `profile show` + `delete` preview mentions source URL, `info` shows Installed: timestamp. * fix(profile-dist): clean errors + warn when overwriting plain profiles Two small polish fixes found during collision sweeps of the PR: 1. ValueError from validate_profile_name now caught cleanly * A distribution.yaml whose 'name' field can't be used as a profile identifier (spaces, path traversal, etc.) raises ValueError from hermes_cli.profiles.validate_profile_name, which was escaping as a raw Python traceback from 'hermes profile install/update/info'. * Broadened the except clause in all three handlers to catch (DistributionError, ValueError) — users now see: Error: Invalid profile name '../../etc/passwd'. Must match [a-z0-9][a-z0-9_-]{0,63} instead of a stack trace. 2. Install preview distinguishes plain profile overwrite from distribution re-install * When plan.target_dir exists and IS a distribution (has distribution.yaml), preview still shows the mild (profile exists — will overwrite distribution-owned files only) * When plan.target_dir exists but is a HAND-BUILT plain profile (no distribution.yaml), preview now shows a loud warning: ⚠ Profile exists but is NOT a distribution. Installing here will overwrite its SOUL.md, skills/, cron/, and mcp.json. Your memories, sessions, auth.json, and .env will be preserved, but any hand-edits to distribution-owned files will be lost. * Users who type 'hermes profile install foo --force' against a profile they hand-built now see what they're signing up for. User data is still safe (memories, sessions, auth, .env are in USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped. Tests (+2): * TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback * TestErrorSurfaces.test_path_traversal_name_rejected Validation: * 165/165 tests pass (was 163). * E2E: bad manifest names produce 'Error: Invalid profile name ...' with no traceback; installing over a plain profile shows the warning; re-installing over an existing distribution shows the normal overwrite message. * Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git itself generates a clean enough message that no wrapper is needed. * 'install .' works correctly from any cwd. * fix(profiles): reject reserved names at validate time Before: `hermes profile create hermes` / `profile install` / `profile rename` all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`, `sudo`. The profile directory was created; only alias creation failed (via check_alias_collision), leaving a confusingly-named profile on disk — e.g. `~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself. The reserved set already exists (_RESERVED_NAMES, introduced alongside alias collision detection). This commit moves the check up one layer to validate_profile_name so every entry point — create, install, import, rename, dashboard web API — shares the same gate. The error message points the user at the cause without being cryptic: Error: Profile name 'hermes' is reserved — it collides with either the Hermes installation itself or a common system binary. Pick a different name. `default` continues to pass through (it's a special alias for ~/.hermes). _HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at alias-collision time only — those are fine as bare profile names with `--no-alias`. Tests (+5): test_reserved_names_rejected parametrized over the full _RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName. No existing test uses a reserved name as a profile identifier (greppped create_profile("hermes|test|tmp|root|sudo") — zero hits). Validation: * 170/170 tests pass in the profile suites. * E2E: `profile create hermes`, `profile install` with manifest name=hermes, and `profile install ... --name hermes` all produce the same clean `Error: Profile name 'hermes' is reserved ...` with rc=1 and no traceback. Normal names (`mybot`) still work.
584 lines
23 KiB
Python
584 lines
23 KiB
Python
"""Tests for hermes_cli.profile_distribution — git-based profile installs.
|
|
|
|
Covers manifest parsing, version requirement checks, install / update / describe
|
|
on local-directory sources, and guards on what can and can't be installed.
|
|
|
|
Transport-layer tests (git clone, URL handling) are exercised through live
|
|
E2E runs, not unit tests — git itself is tested upstream, and subprocess-
|
|
mocking git would just test the mock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.profile_distribution import (
|
|
DEFAULT_DIST_OWNED,
|
|
DistributionError,
|
|
DistributionManifest,
|
|
EnvRequirement,
|
|
MANIFEST_FILENAME,
|
|
USER_OWNED_EXCLUDE,
|
|
_env_template_from_manifest,
|
|
_looks_like_git_url,
|
|
_parse_semver,
|
|
check_hermes_requires,
|
|
describe_distribution,
|
|
install_distribution,
|
|
plan_install,
|
|
read_manifest,
|
|
update_distribution,
|
|
write_manifest,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Isolated profile env (matches tests/hermes_cli/test_profiles.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def profile_env(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
return tmp_path
|
|
|
|
|
|
def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionManifest = None) -> Path:
|
|
"""Build a local distribution staging directory (what a git clone would
|
|
contain after .git is removed).
|
|
|
|
Lays down a minimal but representative tree: SOUL.md, config.yaml,
|
|
mcp.json, one skill, one cron file, plus the distribution.yaml manifest.
|
|
"""
|
|
staged = root / f"staging_{name}"
|
|
staged.mkdir(parents=True, exist_ok=True)
|
|
(staged / "SOUL.md").write_text("I am Source.\n")
|
|
(staged / "config.yaml").write_text("model:\n model: gpt-4\n")
|
|
(staged / "mcp.json").write_text('{"servers": {}}\n')
|
|
(staged / "skills").mkdir(exist_ok=True)
|
|
(staged / "skills" / "demo").mkdir(exist_ok=True)
|
|
(staged / "skills" / "demo" / "SKILL.md").write_text(
|
|
"---\nname: demo\ndescription: test\n---\n# Demo skill\n"
|
|
)
|
|
(staged / "cron").mkdir(exist_ok=True)
|
|
(staged / "cron" / "daily.json").write_text('{"schedule": "0 9 * * *"}')
|
|
|
|
mf = manifest or DistributionManifest(name=name, version="0.1.0")
|
|
write_manifest(staged, mf)
|
|
return staged
|
|
|
|
|
|
# ===========================================================================
|
|
# Manifest parsing
|
|
# ===========================================================================
|
|
|
|
|
|
class TestManifestParsing:
|
|
|
|
def test_minimal_manifest(self, tmp_path):
|
|
(tmp_path / MANIFEST_FILENAME).write_text("name: minimal\n")
|
|
m = read_manifest(tmp_path)
|
|
assert m.name == "minimal"
|
|
assert m.version == "0.1.0"
|
|
assert m.env_requires == []
|
|
assert m.distribution_owned == []
|
|
|
|
def test_full_manifest(self, tmp_path):
|
|
(tmp_path / MANIFEST_FILENAME).write_text(
|
|
"name: telem\n"
|
|
"version: 1.2.3\n"
|
|
"description: Telem monitor\n"
|
|
"hermes_requires: '>=0.12.0'\n"
|
|
"author: Kyle\n"
|
|
"license: MIT\n"
|
|
"env_requires:\n"
|
|
" - name: OPENAI_API_KEY\n"
|
|
" description: OpenAI key\n"
|
|
" - name: GRAPH_URL\n"
|
|
" required: false\n"
|
|
" default: http://127.0.0.1:8000\n"
|
|
"distribution_owned:\n"
|
|
" - SOUL.md\n"
|
|
" - skills/\n"
|
|
)
|
|
m = read_manifest(tmp_path)
|
|
assert m.name == "telem"
|
|
assert m.version == "1.2.3"
|
|
assert m.author == "Kyle"
|
|
assert m.license == "MIT"
|
|
assert len(m.env_requires) == 2
|
|
assert m.env_requires[0].name == "OPENAI_API_KEY"
|
|
assert m.env_requires[0].required is True
|
|
assert m.env_requires[1].required is False
|
|
assert m.env_requires[1].default == "http://127.0.0.1:8000"
|
|
assert m.distribution_owned == ["SOUL.md", "skills"]
|
|
|
|
def test_missing_name_rejected(self, tmp_path):
|
|
(tmp_path / MANIFEST_FILENAME).write_text("version: 1.0\n")
|
|
with pytest.raises(DistributionError, match="missing 'name'"):
|
|
read_manifest(tmp_path)
|
|
|
|
def test_env_requires_not_list_rejected(self, tmp_path):
|
|
(tmp_path / MANIFEST_FILENAME).write_text(
|
|
"name: bad\nenv_requires:\n name: FOO\n"
|
|
)
|
|
with pytest.raises(DistributionError, match="env_requires must be a list"):
|
|
read_manifest(tmp_path)
|
|
|
|
def test_read_manifest_returns_none_when_absent(self, tmp_path):
|
|
assert read_manifest(tmp_path) is None
|
|
|
|
def test_owned_paths_default(self):
|
|
m = DistributionManifest(name="x")
|
|
assert m.owned_paths() == list(DEFAULT_DIST_OWNED)
|
|
|
|
def test_owned_paths_explicit(self):
|
|
m = DistributionManifest(name="x", distribution_owned=["SOUL.md", "skills"])
|
|
assert m.owned_paths() == ["SOUL.md", "skills"]
|
|
|
|
def test_roundtrip_write_read(self, tmp_path):
|
|
original = DistributionManifest(
|
|
name="rt",
|
|
version="1.0.0",
|
|
description="roundtrip",
|
|
env_requires=[EnvRequirement(name="FOO", description="foo")],
|
|
)
|
|
write_manifest(tmp_path, original)
|
|
parsed = read_manifest(tmp_path)
|
|
assert parsed.name == "rt"
|
|
assert parsed.env_requires[0].name == "FOO"
|
|
|
|
|
|
# ===========================================================================
|
|
# Version requirement checks
|
|
# ===========================================================================
|
|
|
|
|
|
class TestVersionRequires:
|
|
|
|
@pytest.mark.parametrize("spec,cur,ok", [
|
|
("", "0.1.0", True),
|
|
(">=0.12.0", "0.12.0", True),
|
|
(">=0.12.0", "0.13.0", True),
|
|
(">=0.12.0", "0.11.9", False),
|
|
("==0.12.0", "0.12.0", True),
|
|
("==0.12.0", "0.13.0", False),
|
|
("!=0.12.0", "0.13.0", True),
|
|
(">0.12.0", "0.12.1", True),
|
|
(">0.12.0", "0.12.0", False),
|
|
("<0.13.0", "0.12.9", True),
|
|
("<=0.12.0", "0.12.0", True),
|
|
("0.12.0", "0.13.0", True), # Bare = >=
|
|
("0.12.0", "0.11.0", False), # Bare = >=
|
|
])
|
|
def test_check_matrix(self, spec, cur, ok):
|
|
if ok:
|
|
check_hermes_requires(spec, cur)
|
|
else:
|
|
with pytest.raises(DistributionError, match="requires Hermes"):
|
|
check_hermes_requires(spec, cur)
|
|
|
|
def test_parse_semver_handles_prerelease(self):
|
|
assert _parse_semver("0.12.0-rc1") == (0, 12, 0)
|
|
assert _parse_semver("v0.12.0+abc") == (0, 12, 0)
|
|
|
|
def test_parse_semver_pads(self):
|
|
assert _parse_semver("1") == (1, 0, 0)
|
|
assert _parse_semver("1.2") == (1, 2, 0)
|
|
|
|
def test_parse_semver_rejects_garbage(self):
|
|
with pytest.raises(DistributionError, match="Unparseable"):
|
|
_parse_semver("not-a-version")
|
|
|
|
|
|
# ===========================================================================
|
|
# Env template
|
|
# ===========================================================================
|
|
|
|
|
|
class TestEnvTemplate:
|
|
|
|
def test_required_is_uncommented(self):
|
|
m = DistributionManifest(
|
|
name="x",
|
|
env_requires=[EnvRequirement(name="FOO", description="foo key")],
|
|
)
|
|
out = _env_template_from_manifest(m)
|
|
assert "# foo key" in out
|
|
assert "# (required)" in out
|
|
assert "FOO=" in out
|
|
# No leading `# ` before FOO=
|
|
assert "\nFOO=" in out or out.startswith("FOO=") or "\nFOO=\n" in out or "FOO=\n" in out
|
|
|
|
def test_optional_is_commented(self):
|
|
m = DistributionManifest(
|
|
name="x",
|
|
env_requires=[EnvRequirement(name="BAR", required=False, default="http://x")],
|
|
)
|
|
out = _env_template_from_manifest(m)
|
|
assert "# (optional)" in out
|
|
assert "# BAR=http://x" in out
|
|
|
|
def test_empty_env_requires_is_header_only(self):
|
|
m = DistributionManifest(name="x")
|
|
out = _env_template_from_manifest(m)
|
|
assert "Hermes distribution" in out
|
|
assert "FOO" not in out
|
|
|
|
|
|
# ===========================================================================
|
|
# Source URL detection
|
|
# ===========================================================================
|
|
|
|
|
|
class TestLooksLikeGitUrl:
|
|
|
|
@pytest.mark.parametrize("src", [
|
|
"github.com/user/repo",
|
|
"https://github.com/user/repo",
|
|
"https://github.com/user/repo.git",
|
|
"http://example.com/repo",
|
|
"git@github.com:user/repo.git",
|
|
"ssh://git@example.com/repo.git",
|
|
"git://example.com/repo.git",
|
|
])
|
|
def test_accepts_git_sources(self, src):
|
|
assert _looks_like_git_url(src)
|
|
|
|
@pytest.mark.parametrize("src", [
|
|
"/tmp/local/path",
|
|
"./relative/dir",
|
|
"~/profile",
|
|
"some-random-string",
|
|
])
|
|
def test_rejects_non_git(self, src):
|
|
assert not _looks_like_git_url(src)
|
|
|
|
|
|
# ===========================================================================
|
|
# Install — fresh and force (from a local-directory source)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestInstall:
|
|
|
|
def test_install_from_directory(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
plan = install_distribution(str(staged), name="installed")
|
|
assert plan.target_dir.is_dir()
|
|
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
|
|
assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists()
|
|
assert (plan.target_dir / "mcp.json").exists()
|
|
# Manifest on disk records canonical name + provenance
|
|
m = read_manifest(plan.target_dir)
|
|
assert m.name == "installed"
|
|
assert m.source == str(staged)
|
|
|
|
def test_install_uses_manifest_name_when_no_override(self, profile_env):
|
|
mf = DistributionManifest(name="telem", version="1.0.0")
|
|
staged = _make_staging_dir(profile_env, "telem", manifest=mf)
|
|
plan = install_distribution(str(staged))
|
|
assert plan.manifest.name == "telem"
|
|
assert plan.target_dir.name == "telem"
|
|
|
|
def test_install_rejects_existing_without_force(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
install_distribution(str(staged), name="existing")
|
|
with pytest.raises(DistributionError, match="already exists"):
|
|
install_distribution(str(staged), name="existing")
|
|
|
|
def test_install_with_force_overwrites(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
install_distribution(str(staged), name="target")
|
|
# Install again with --force succeeds
|
|
plan = install_distribution(str(staged), name="target", force=True)
|
|
assert plan.target_dir.is_dir()
|
|
|
|
def test_install_rejects_default_name(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
with pytest.raises(DistributionError, match="Cannot install"):
|
|
install_distribution(str(staged), name="default")
|
|
|
|
def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path):
|
|
bogus = tmp_path / "bogus_dir"
|
|
bogus.mkdir()
|
|
(bogus / "some_file").write_text("hi")
|
|
with pytest.raises(DistributionError, match="No distribution.yaml"):
|
|
plan_install(str(bogus), tmp_path / "work", override_name="x")
|
|
|
|
def test_install_rejects_unknown_source(self, profile_env, tmp_path):
|
|
with pytest.raises(DistributionError, match="Cannot resolve"):
|
|
plan_install("definitely-not-a-thing", tmp_path / "work", override_name="x")
|
|
|
|
def test_install_emits_env_example_when_manifest_has_env(self, profile_env):
|
|
mf = DistributionManifest(
|
|
name="needs_env",
|
|
version="0.1.0",
|
|
env_requires=[EnvRequirement(name="OPENAI_API_KEY", description="key")],
|
|
)
|
|
staged = _make_staging_dir(profile_env, "needs_env", manifest=mf)
|
|
plan = install_distribution(str(staged), name="needs_env")
|
|
example = plan.target_dir / ".env.EXAMPLE"
|
|
assert example.is_file()
|
|
assert "OPENAI_API_KEY" in example.read_text()
|
|
|
|
def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
|
|
# Pin current Hermes version to something well below the requirement
|
|
import hermes_cli
|
|
monkeypatch.setattr(hermes_cli, "__version__", "0.1.0", raising=False)
|
|
|
|
mf = DistributionManifest(
|
|
name="future",
|
|
version="1.0.0",
|
|
hermes_requires=">=99.0.0",
|
|
)
|
|
staged = _make_staging_dir(profile_env, "future", manifest=mf)
|
|
with pytest.raises(DistributionError, match="requires Hermes"):
|
|
install_distribution(str(staged), name="future")
|
|
|
|
|
|
# ===========================================================================
|
|
# Update — preserves user data, preserves config by default
|
|
# ===========================================================================
|
|
|
|
|
|
class TestUpdate:
|
|
|
|
def test_update_preserves_user_data(self, profile_env):
|
|
# 1. Build staging dir, install
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
plan = install_distribution(str(staged), name="telem")
|
|
|
|
# 2. Add user-owned data to the installed profile
|
|
(plan.target_dir / "memories").mkdir(exist_ok=True)
|
|
(plan.target_dir / "memories" / "MEMORY.md").write_text("# USER MEMORY\n")
|
|
(plan.target_dir / ".env").write_text("OPENAI_API_KEY=sk-user\n")
|
|
(plan.target_dir / "auth.json").write_text('{"user": "auth"}')
|
|
(plan.target_dir / "sessions").mkdir(exist_ok=True)
|
|
(plan.target_dir / "sessions" / "chat.json").write_text('{"s": 1}')
|
|
|
|
# 3. Bump source in the staging dir
|
|
(staged / "SOUL.md").write_text("I am Source v2.\n")
|
|
|
|
# 4. Update
|
|
update_distribution("telem", force_config=False)
|
|
|
|
# 5. Dist-owned changed
|
|
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source v2.\n"
|
|
# 6. User-owned preserved
|
|
assert (plan.target_dir / "memories" / "MEMORY.md").read_text() == "# USER MEMORY\n"
|
|
assert (plan.target_dir / ".env").read_text() == "OPENAI_API_KEY=sk-user\n"
|
|
assert (plan.target_dir / "auth.json").read_text() == '{"user": "auth"}'
|
|
assert (plan.target_dir / "sessions" / "chat.json").read_text() == '{"s": 1}'
|
|
|
|
def test_update_preserves_config_by_default(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
plan = install_distribution(str(staged), name="t2")
|
|
|
|
# User edits config
|
|
(plan.target_dir / "config.yaml").write_text(
|
|
"model:\n model: gpt-5\n# user override\n"
|
|
)
|
|
|
|
# Bump source config
|
|
(staged / "config.yaml").write_text("model:\n model: claude\n")
|
|
|
|
update_distribution("t2", force_config=False)
|
|
assert "gpt-5" in (plan.target_dir / "config.yaml").read_text()
|
|
assert "user override" in (plan.target_dir / "config.yaml").read_text()
|
|
|
|
def test_update_force_config_overwrites(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
plan = install_distribution(str(staged), name="t3")
|
|
|
|
(plan.target_dir / "config.yaml").write_text("model:\n model: gpt-5\n")
|
|
|
|
(staged / "config.yaml").write_text("model:\n model: claude\n")
|
|
|
|
update_distribution("t3", force_config=True)
|
|
assert "claude" in (plan.target_dir / "config.yaml").read_text()
|
|
assert "gpt-5" not in (plan.target_dir / "config.yaml").read_text()
|
|
|
|
def test_update_missing_manifest_errors(self, profile_env):
|
|
# Make a profile without a manifest; update must refuse
|
|
from hermes_cli.profiles import create_profile
|
|
create_profile(name="plain", no_alias=True)
|
|
with pytest.raises(DistributionError, match="not a distribution"):
|
|
update_distribution("plain")
|
|
|
|
|
|
# ===========================================================================
|
|
# describe_distribution — info subcommand
|
|
# ===========================================================================
|
|
|
|
|
|
class TestDescribe:
|
|
|
|
def test_describe_existing_distribution(self, profile_env):
|
|
mf = DistributionManifest(
|
|
name="telem",
|
|
version="1.0.0",
|
|
description="compliance monitor",
|
|
env_requires=[EnvRequirement(name="API", description="api key")],
|
|
)
|
|
staged = _make_staging_dir(profile_env, "telem", manifest=mf)
|
|
install_distribution(str(staged), name="telem")
|
|
data = describe_distribution("telem")
|
|
assert data["name"] == "telem"
|
|
assert data["version"] == "1.0.0"
|
|
assert data["env_requires"][0]["name"] == "API"
|
|
|
|
def test_describe_non_distribution_returns_empty(self, profile_env):
|
|
from hermes_cli.profiles import create_profile
|
|
create_profile(name="plain", no_alias=True)
|
|
assert describe_distribution("plain") == {}
|
|
|
|
def test_describe_missing_profile_raises(self, profile_env):
|
|
with pytest.raises(DistributionError, match="does not exist"):
|
|
describe_distribution("nonexistent")
|
|
|
|
|
|
# ===========================================================================
|
|
# Security — USER_OWNED_EXCLUDE covers the right paths
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSecurity:
|
|
|
|
def test_user_owned_exclude_covers_credentials(self):
|
|
assert "auth.json" in USER_OWNED_EXCLUDE
|
|
assert ".env" in USER_OWNED_EXCLUDE
|
|
assert "memories" in USER_OWNED_EXCLUDE
|
|
assert "sessions" in USER_OWNED_EXCLUDE
|
|
assert "local" in USER_OWNED_EXCLUDE
|
|
|
|
def test_install_does_not_import_credentials_from_staging(self, profile_env):
|
|
"""If an author accidentally ships auth.json or .env in their
|
|
staging dir, the installer must NOT copy them to the target profile."""
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
# Author leaks credentials into the staging tree (shouldn't happen, but...)
|
|
(staged / "auth.json").write_text('{"leaked": true}')
|
|
(staged / ".env").write_text("LEAKED=1")
|
|
|
|
plan = install_distribution(str(staged), name="clean")
|
|
assert not (plan.target_dir / "auth.json").exists(), "auth.json leaked"
|
|
# Fresh profile may have its own .env via the bootstrap; what we care
|
|
# about is that the leaked content didn't land in the target.
|
|
if (plan.target_dir / ".env").exists():
|
|
assert "LEAKED" not in (plan.target_dir / ".env").read_text()
|
|
|
|
|
|
# ===========================================================================
|
|
# Install-time metadata (installed_at stamp)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestInstalledAtStamp:
|
|
|
|
def test_install_stamps_installed_at(self, profile_env):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
plan = install_distribution(str(staged), name="stamped")
|
|
mf = read_manifest(plan.target_dir)
|
|
assert mf.installed_at, "installed_at should be set after install"
|
|
# ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'.
|
|
assert mf.installed_at[:4].isdigit()
|
|
assert "T" in mf.installed_at
|
|
assert mf.installed_at.endswith("+00:00")
|
|
|
|
def test_update_refreshes_installed_at(self, profile_env, monkeypatch):
|
|
staged = _make_staging_dir(profile_env, "src")
|
|
install_distribution(str(staged), name="demo")
|
|
from hermes_cli.profiles import get_profile_dir
|
|
first = read_manifest(get_profile_dir("demo")).installed_at
|
|
|
|
# Freeze `datetime.now()` to a fixed future time so we can observe that
|
|
# update writes a NEW stamp (installs within the same second otherwise
|
|
# collide at iso-8601 seconds resolution).
|
|
import datetime as _dt
|
|
class _FakeDT(_dt.datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profile_distribution.datetime", _FakeDT, raising=True
|
|
)
|
|
|
|
from hermes_cli.profile_distribution import update_distribution
|
|
update_distribution("demo")
|
|
refreshed = read_manifest(get_profile_dir("demo")).installed_at
|
|
assert refreshed != first, "installed_at should change on update"
|
|
assert refreshed.startswith("2099-01-01"), refreshed
|
|
|
|
|
|
# ===========================================================================
|
|
# ProfileInfo exposes distribution metadata
|
|
# ===========================================================================
|
|
|
|
|
|
class TestProfileInfoDistribution:
|
|
|
|
def test_installed_distribution_shows_in_list(self, profile_env):
|
|
staged = _make_staging_dir(
|
|
profile_env, "src",
|
|
manifest=DistributionManifest(name="telem", version="1.2.3"),
|
|
)
|
|
install_distribution(str(staged), name="telem")
|
|
|
|
from hermes_cli.profiles import list_profiles
|
|
rows = {p.name: p for p in list_profiles()}
|
|
assert "telem" in rows
|
|
row = rows["telem"]
|
|
assert row.distribution_name == "telem"
|
|
assert row.distribution_version == "1.2.3"
|
|
assert row.distribution_source # path populated, exact value depends on fixture
|
|
|
|
def test_plain_profile_has_no_distribution_fields(self, profile_env):
|
|
from hermes_cli.profiles import create_profile, list_profiles
|
|
create_profile(name="plain", no_alias=True)
|
|
rows = {p.name: p for p in list_profiles()}
|
|
assert rows["plain"].distribution_name is None
|
|
assert rows["plain"].distribution_version is None
|
|
|
|
def test_malformed_manifest_does_not_break_list(self, profile_env):
|
|
from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir
|
|
create_profile(name="brokenmeta", no_alias=True)
|
|
# Write a distribution.yaml that isn't a valid mapping
|
|
(get_profile_dir("brokenmeta") / "distribution.yaml").write_text(
|
|
"not: [a, valid, mapping\n" # broken YAML
|
|
)
|
|
# list_profiles must NOT raise; distribution_* stay None for this row.
|
|
rows = {p.name: p for p in list_profiles()}
|
|
assert rows["brokenmeta"].distribution_name is None
|
|
|
|
|
|
# ===========================================================================
|
|
# Error surfaces: validation failures should propagate as DistributionError
|
|
# or ValueError (both caught and rendered cleanly by the CLI handler)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestErrorSurfaces:
|
|
|
|
def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path):
|
|
"""A manifest whose 'name' can't be used as a profile identifier
|
|
should raise ValueError from validate_profile_name — the CLI handler
|
|
catches both DistributionError and ValueError so users see a clean
|
|
'Error: ...' line instead of a Python traceback.
|
|
"""
|
|
mf = DistributionManifest(name="Invalid Name With Spaces", version="0.1.0")
|
|
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
|
|
with pytest.raises((ValueError, DistributionError)):
|
|
plan_install(str(staged), tmp_path / "work")
|
|
|
|
def test_path_traversal_name_rejected(self, profile_env, tmp_path):
|
|
mf = DistributionManifest(name="../../etc/passwd", version="0.1.0")
|
|
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
|
|
with pytest.raises((ValueError, DistributionError)):
|
|
plan_install(str(staged), tmp_path / "work")
|
|
|