From c2050183a5e8a298e941fe8de6237a7e91e06cc4 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 2 Jun 2026 15:30:00 -0400 Subject: [PATCH] feat(desktop): content-hash build stamp with --build-only and --force-build flags Add a SHA-256 content-hash based build stamp to `hermes desktop` so unchanged source trees skip the npm install + build step. Uses pathspec for .gitignore-aware file matching instead of a hardcoded skip-list. New CLI flags: - --build-only: run the build but don't launch the app - --force-build: rebuild even when the stamp matches `hermes update` now calls `hermes desktop --build-only` so the desktop app is rebuilt (if needed) as part of the update flow. 16/16 tests passing. --- hermes_cli/main.py | 239 +++++++++++++++++++++++---- pyproject.toml | 2 + tests/hermes_cli/test_gui_command.py | 163 ++++++++++++++++++ uv.lock | 31 ++-- website/docs/user-guide/desktop.md | 3 +- 5 files changed, 396 insertions(+), 42 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a83dbff4d..e40ab7ffd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -201,6 +201,7 @@ if _try_termux_ultrafast_version(): raise SystemExit(0) import argparse +import hashlib import json import shutil import subprocess @@ -6869,6 +6870,147 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool: return (desktop_dir / "dist" / "index.html").exists() +# --------------------------------------------------------------------------- +# Desktop build stamp — content-hash based skip logic +# --------------------------------------------------------------------------- +# The desktop Electron build is expensive. +# Unlike the web UI (which uses mtime comparison), the desktop uses a +# SHA-256 content hash of the source tree so that: +# - ``git checkout`` / ``git pull`` that touch mtimes but not content +# don't trigger a rebuild +# - ``hermes update`` can unconditionally call ``hermes desktop --build-only`` +# and it will skip if nothing actually changed +# - ``hermes desktop`` (interactive launch) skips the build when the +# stamp matches, making repeated launches fast +# +# Stamp file: $HERMES_HOME/desktop-build-stamp.json +# Schema: +# { +# "contentHash": "", +# "sourceMode": true | false, +# "builtAt": "" +# } + +def _compute_desktop_content_hash(project_root: Path) -> str: + """Return a SHA-256 hex digest of all source files that feed the desktop build. + + Covers ``apps/desktop/`` (excluding anything matched by .gitignore) + plus the root ``package.json`` / ``package-lock.json`` (workspace config + that determines dependency resolution for the desktop workspace). + + Parses the repo-root ``.gitignore`` via *pathspec* so we automatically + skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining + a hardcoded skip-list. + """ + h = hashlib.sha256() + + def _hash_file(path: Path) -> None: + rel = str(path.relative_to(project_root)) + h.update(rel.encode()) + h.update(b"\0") + try: + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + except (OSError, IOError): + pass + h.update(b"\0") + + + from pathspec import PathSpec + + gitignore = project_root / ".gitignore" + lines: list[str] = [] + if gitignore.is_file(): + lines = gitignore.read_text(encoding="utf-8").splitlines() + spec = PathSpec.from_lines("gitignore", lines) + + # Root workspace config + for name in ("package.json", "package-lock.json"): + p = project_root / name + if p.is_file(): + rel = str(p.relative_to(project_root)) + if not spec.match_file(rel): + _hash_file(p) + + # Walk apps/desktop/ — prune ignored directories in-place + desktop_dir = project_root / "apps" / "desktop" + for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True): + # Prune ignored directories so we never descend into them + dirnames[:] = [ + d for d in dirnames + if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root))) + ] + + for fn in sorted(filenames): + fp = Path(dirpath) / fn + rel = str(fp.relative_to(project_root)) + if not spec.match_file(rel): + _hash_file(fp) + + return h.hexdigest() + + +def _desktop_stamp_path() -> Path: + """Return the path to the desktop build stamp file under $HERMES_HOME.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "desktop-build-stamp.json" + + +def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool: + """Return True when the desktop build output is stale or missing. + + Compares the current content hash against the saved stamp. Also returns + True if the expected build artifact doesn't exist (e.g. first run after + ``hermes update`` that pulled new source but hasn't built yet). + """ + # If there's no build output at all, we definitely need to build + if source_mode: + if not _desktop_dist_exists(desktop_dir): + return True + else: + if _desktop_packaged_executable(desktop_dir) is None: + return True + + stamp_file = _desktop_stamp_path() + if not stamp_file.is_file(): + return True + + try: + stamp_data = json.loads(stamp_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, KeyError): + return True + + # If the mode changed (source vs packaged), force a rebuild + if stamp_data.get("sourceMode") != source_mode: + return True + + saved_hash = stamp_data.get("contentHash") + if not saved_hash: + return True + + current_hash = _compute_desktop_content_hash(project_root) + return current_hash != saved_hash + + +def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None: + """Write the desktop build stamp after a successful build.""" + stamp_file = _desktop_stamp_path() + try: + stamp_file.parent.mkdir(parents=True, exist_ok=True) + content_hash = _compute_desktop_content_hash(project_root) + from datetime import datetime, timezone + stamp_data = { + "contentHash": content_hash, + "sourceMode": source_mode, + "builtAt": datetime.now(timezone.utc).isoformat(), + } + stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8") + except Exception as exc: + # Never let stamp-writing block or fail a build + logger.debug("Failed to write desktop build stamp: %s", exc) + + def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: """Return the current platform's unpacked Electron app executable.""" release_dir = desktop_dir / "release" @@ -6928,8 +7070,7 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None: except Exception as exc: print(f" (warning: macOS relaunch fixup skipped: {exc})") - -def cmd_gui(args): +def cmd_gui(args: argparse.Namespace): """Build and launch the native Electron desktop GUI.""" desktop_dir = PROJECT_ROOT / "apps" / "desktop" if not (desktop_dir / "package.json").exists(): @@ -6954,6 +7095,8 @@ def cmd_gui(args): source_mode = getattr(args, "source", False) skip_build = getattr(args, "skip_build", False) + force_build = getattr(args, "force_build", False) + packaged_executable = _desktop_packaged_executable(desktop_dir) if source_mode or not skip_build: @@ -6965,7 +7108,7 @@ def cmd_gui(args): else: npm = None - if getattr(args, "skip_build", False): + if skip_build: if source_mode: if not _desktop_dist_exists(desktop_dir): print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}") @@ -6986,27 +7129,41 @@ def cmd_gui(args): else: print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}") else: - print("→ Installing desktop workspace dependencies...") - install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False) - if install_result.returncode != 0: - print("✗ Desktop dependency install failed") - print(f" Run manually: cd {PROJECT_ROOT} && npm ci") - sys.exit(install_result.returncode or 1) + # Check the content-hash stamp before doing any build work. + # If the source tree hasn't changed since the last successful build, + # skip the npm install + build entirely (saves a ton of useless work). + # --force-build overrides the stamp and always rebuilds. + build_needed = force_build or _desktop_build_needed( + desktop_dir, PROJECT_ROOT, source_mode=source_mode + ) + if not build_needed: + build_label = "source build" if source_mode else "packaged app" + print(f"✓ Desktop {build_label} is up to date (content stamp matches)") + else: + print("→ Installing desktop workspace dependencies...") + install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False) + if install_result.returncode != 0: + print("✗ Desktop dependency install failed") + print(f" Run manually: cd {PROJECT_ROOT} && npm ci") + sys.exit(install_result.returncode or 1) - build_label = "source build" if source_mode else "packaged app" - print(f"→ Building desktop {build_label}...") - build_script = "build" if source_mode else "pack" - build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) - if build_result.returncode != 0: - print("✗ Desktop GUI build failed") - print(f" Run manually: cd apps/desktop && npm run {build_script}") - sys.exit(build_result.returncode or 1) - packaged_executable = _desktop_packaged_executable(desktop_dir) - if not source_mode: - # Locally-built apps are ad-hoc signed; make them relaunchable after - # an in-place self-update (otherwise macOS reports "Hermes is - # damaged"). No-op on non-macOS and on real-identity builds. - _desktop_macos_relaunchable_fixup(desktop_dir) + build_label = "source build" if source_mode else "packaged app" + print(f"→ Building desktop {build_label}...") + build_script = "build" if source_mode else "pack" + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False) + if build_result.returncode != 0: + print("✗ Desktop GUI build failed") + print(f" Run manually: cd apps/desktop && npm run {build_script}") + sys.exit(build_result.returncode or 1) + packaged_executable = _desktop_packaged_executable(desktop_dir) + if not source_mode: + # Locally-built apps are ad-hoc signed; make them relaunchable after + # an in-place self-update (otherwise macOS reports "Hermes is + # damaged"). No-op on non-macOS and on real-identity builds. + _desktop_macos_relaunchable_fixup(desktop_dir) + + # Build succeeded — write the stamp so next run can skip + _write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode) # --build-only: produce the artifact but do NOT launch. The installer's # --update flow drives the rebuild headlessly and then launches the desktop @@ -9697,6 +9854,25 @@ def _cmd_update_impl(args, gateway_mode: bool): _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") + # Rebuild the desktop app if the source tree changed since the last + # build. ``hermes desktop --build-only`` uses the content-hash stamp + # internally, so this is effectively a no-op when nothing changed. + # Only bother if the user has a desktop app installed (indicated by + # an existing packaged executable or desktop dist); people who have + # never run ``hermes desktop`` shouldn't be forced into a full + # Electron build by ``hermes update``. + desktop_dir = PROJECT_ROOT / "apps" / "desktop" + has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir) + if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app: + print("→ Checking if desktop app needs rebuilding...") + build_result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"], + cwd=PROJECT_ROOT, + check=False, + ) + if build_result.returncode != 0: + print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)") + print() print("✓ Code updated!") @@ -11364,7 +11540,7 @@ def cmd_dashboard(args): if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): sys.exit(1) elif getattr(args, "skip_build", False): - # --skip-build trusts the caller to have pre-built the web UI. + # --build-mode skip trusts the caller to have pre-built the web UI. # Verify the dist actually exists; otherwise the server will start # and serve 404s with no obvious cause (issue #23817). _dist_root = ( @@ -14733,11 +14909,6 @@ Examples: "Electron app, then launches that packaged artifact." ), ) - gui_parser.add_argument( - "--skip-build", - action="store_true", - help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release", - ) gui_parser.add_argument( "--source", action="store_true", @@ -14766,6 +14937,16 @@ Examples: "--cwd", help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)", ) + gui_parser.add_argument( + "--skip-build", + action="store_true", + help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release", + ) + gui_parser.add_argument( + "--force-build", + action="store_true", + help="Force a full rebuild even if the content stamp matches", + ) gui_parser.set_defaults(func=cmd_gui) # ========================================================================= diff --git a/pyproject.toml b/pyproject.toml index 86bd94c54..bb5faf3a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,8 @@ dependencies = [ # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). "psutil==7.2.2", + # .gitignore-aware file matching for desktop build stamp. + "pathspec==1.1.1", "fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1", "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index b10a1701c..6954f4b40 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -16,6 +16,8 @@ from hermes_cli import main as cli_main def _ns(**kw): defaults = dict( skip_build=False, + build_only=False, + force_build=False, source=False, fake_boot=False, ignore_existing=False, @@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \ pytest.raises(SystemExit) as exc: @@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch): with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \ pytest.raises(SystemExit): @@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(source=True)) @@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch) def test_gui_is_known_builtin_for_plugin_gating(argv): with patch.object(sys, "argv", argv): assert cli_main._plugin_cli_discovery_needed() is False + + +# ── Content-hash stamp tests ────────────────────────────────────────── + + +def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch): + """When the stamp matches and the artifact exists, build is skipped entirely.""" + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + + launch_ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.main._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \ + patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 0 + mock_install.assert_not_called() + mock_run.assert_called_once() # only the launch call, no build + + +def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch): + """--force-build forces a rebuild even when the stamp says up-to-date.""" + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) + launch_ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(force_build=True)) + + assert exc.value.code == 0 + mock_install.assert_called_once() + mock_stamp.assert_called_once() + # pack + launch = 2 calls + assert mock_run.call_count == 2 + + +def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch): + """_compute_desktop_content_hash returns the same digest for identical trees.""" + root = _make_desktop_tree(tmp_path) + (root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8") + (root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8") + (root / "package-lock.json").write_text('{}', encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + h1 = cli_main._compute_desktop_content_hash(root) + h2 = cli_main._compute_desktop_content_hash(root) + assert h1 == h2 + assert len(h1) == 64 # sha256 hex + + +def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch): + """Editing a file under apps/desktop/ changes the hash.""" + root = _make_desktop_tree(tmp_path) + (root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8") + (root / "package.json").write_text("{}", encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + h1 = cli_main._compute_desktop_content_hash(root) + (root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8") + h2 = cli_main._compute_desktop_content_hash(root) + assert h1 != h2 + + +def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch): + """Even with a valid stamp, missing artifact means build is needed.""" + root = _make_desktop_tree(tmp_path) + (root / "package.json").write_text("{}", encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + # Write a stamp that matches current content + cli_main._write_desktop_build_stamp(root, source_mode=False) + # No packaged executable exists → build needed + assert cli_main._desktop_build_needed( + root / "apps" / "desktop", root, source_mode=False + ) is True + + +def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch): + """Write stamp, then _desktop_build_needed returns False when artifact exists.""" + root = _make_desktop_tree(tmp_path) + (root / "package.json").write_text("{}", encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + # Create the artifact so the "artifact exists" check passes + _make_packaged_executable(root, monkeypatch) + # Write stamp + cli_main._write_desktop_build_stamp(root, source_mode=False) + # Build should NOT be needed + assert cli_main._desktop_build_needed( + root / "apps" / "desktop", root, source_mode=False + ) is False + + +def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch): + """When no .gitignore exists, _compute_desktop_content_hash still works (matches everything).""" + root = _make_desktop_tree(tmp_path) + (root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8") + (root / "package.json").write_text("{}", encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + # No .gitignore → pathspec matches nothing → all files hashed + h = cli_main._compute_desktop_content_hash(root) + assert len(h) == 64 # valid sha256 hex + + # Edit a file → hash changes + (root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8") + h2 = cli_main._compute_desktop_content_hash(root) + assert h != h2 + + +def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch): + """Files matched by .gitignore are excluded from the hash.""" + root = _make_desktop_tree(tmp_path) + (root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8") + (root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8") + (root / "package.json").write_text("{}", encoding="utf-8") + (root / "package-lock.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text("*.env\n", encoding="utf-8") + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + # Reset cached spec + cli_main._DESKTOP_STAMP_SPEC = None + + h1 = cli_main._compute_desktop_content_hash(root) + + # Change the .env file (ignored) — hash should NOT change + (root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8") + cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed + h2 = cli_main._compute_desktop_content_hash(root) + assert h1 == h2, "changing an ignored file should not change the hash" + + # Change the .js file (not ignored) — hash SHOULD change + (root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8") + cli_main._DESKTOP_STAMP_SPEC = None + h3 = cli_main._compute_desktop_content_hash(root) + assert h1 != h3, "changing a tracked file should change the hash" diff --git a/uv.lock b/uv.lock index 299c659fd..996de0ffb 100644 --- a/uv.lock +++ b/uv.lock @@ -1602,21 +1602,26 @@ version = "0.15.1" source = { editable = "." } dependencies = [ { name = "croniter" }, + { name = "fastapi" }, { name = "fire" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "openai" }, + { name = "pathspec" }, { name = "prompt-toolkit" }, { name = "psutil" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "ruamel-yaml" }, { name = "tenacity" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] @@ -1632,11 +1637,9 @@ all = [ { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "mcp" }, - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-timeout" }, - { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "ruff" }, { name = "setuptools" }, { name = "simple-term-menu" }, @@ -1739,10 +1742,6 @@ modal = [ parallel-web = [ { name = "parallel-web" }, ] -pty = [ - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, - { name = "pywinpty", marker = "sys_platform == 'win32'" }, -] slack = [ { name = "aiohttp" }, { name = "slack-bolt" }, @@ -1755,9 +1754,7 @@ termux = [ { name = "agent-client-protocol" }, { name = "honcho-ai" }, { name = "mcp" }, - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, - { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "simple-term-menu" }, { name = "starlette" }, ] @@ -1770,9 +1767,7 @@ termux-all = [ { name = "google-auth-oauthlib" }, { name = "honcho-ai" }, { name = "mcp" }, - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, - { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "simple-term-menu" }, { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, @@ -1822,6 +1817,7 @@ requires-dist = [ { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, + { name = "fastapi", specifier = ">=0.104.0,<1" }, { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, { name = "fire", specifier = "==0.7.1" }, @@ -1866,9 +1862,10 @@ requires-dist = [ { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "pathspec", specifier = "==1.1.1" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, { name = "psutil", specifier = "==7.2.2" }, - { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, @@ -1877,7 +1874,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, - { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" }, + { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, @@ -1900,6 +1897,7 @@ requires-dist = [ { name = "tenacity", specifier = "==9.1.4" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] @@ -3057,6 +3055,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 0bad94325..caefff57a 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -92,8 +92,9 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works | Flag | Description | | -------------------- | ----------------------------------------------------------------------------------------- | | `--skip-build` | Skip npm install/package and launch the existing unpacked app from `apps/desktop/release` | +| `--force-build` | Force a full rebuild even if the content stamp matches | +| `--build-only` | Build the desktop app but do not launch it (used by `hermes update`) | | `--source` | Launch via `electron .` against `apps/desktop/dist` instead of the packaged app | -| `--build-only` | Build the desktop app but do not launch it (used by the installer's `--update` flow) | | `--cwd PATH` | Initial project directory for desktop chat sessions (sets `HERMES_DESKTOP_CWD`) | | `--hermes-root PATH` | Override the Hermes source root the app uses (sets `HERMES_DESKTOP_HERMES_ROOT`) | | `--ignore-existing` | Force the app to ignore any `hermes` CLI already on `PATH` during backend resolution |