From 82e45ab4283df9d5ae17b8f123aaa1b1954d0538 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 21 May 2026 21:27:55 -0500 Subject: [PATCH] feat(desktop): launch packaged gui builds by default --- hermes_cli/main.py | 103 +++++++++++++++++++++------ tests/hermes_cli/test_gui_command.py | 73 ++++++++++++++----- 2 files changed, 134 insertions(+), 42 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1575b949f13..10c655b787a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6396,6 +6396,29 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool: return (desktop_dir / "dist" / "index.html").exists() +def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: + """Return the current platform's unpacked Electron app executable.""" + release_dir = desktop_dir / "release" + if sys.platform == "darwin": + candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes")) + elif sys.platform == "win32": + candidates = [ + release_dir / "win-unpacked" / "Hermes.exe", + release_dir / "win-ia32-unpacked" / "Hermes.exe", + release_dir / "win-arm64-unpacked" / "Hermes.exe", + ] + else: + candidates = [ + release_dir / "linux-unpacked" / "hermes", + release_dir / "linux-unpacked" / "Hermes", + ] + + existing = [p for p in candidates if p.exists()] + if not existing: + return None + return max(existing, key=lambda p: p.stat().st_mtime) + + def cmd_gui(args): """Build and launch the native Electron desktop GUI.""" desktop_dir = PROJECT_ROOT / "apps" / "desktop" @@ -6409,12 +6432,6 @@ def cmd_gui(args): except Exception: pass - npm = shutil.which("npm") - if not npm: - print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.") - print("Install Node.js, then run: hermes gui") - sys.exit(1) - env = os.environ.copy() if getattr(args, "fake_boot", False): env["HERMES_DESKTOP_BOOT_FAKE"] = "1" @@ -6425,18 +6442,39 @@ def cmd_gui(args): if getattr(args, "cwd", None): env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve()) + source_mode = getattr(args, "source", False) + skip_build = getattr(args, "skip_build", False) + packaged_executable = _desktop_packaged_executable(desktop_dir) + + if source_mode or not skip_build: + npm = shutil.which("npm") + if not npm: + print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.") + print("Install Node.js, then run: hermes gui") + sys.exit(1) + else: + npm = None + if getattr(args, "skip_build", False): - if not _desktop_dist_exists(desktop_dir): - print(f"✗ --skip-build was passed but no desktop dist found at: {desktop_dir / 'dist'}") - print(" Pre-build first: cd apps/desktop && npm run build") - print(" Or drop --skip-build to install dependencies and build automatically.") + 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'}") + print(" Pre-build first: cd apps/desktop && npm run build") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists(): + print("✗ --skip-build --source requires existing workspace dependencies.") + print(f" Install first: cd {PROJECT_ROOT} && npm ci") + print(" Or drop --skip-build to install dependencies and build automatically.") + sys.exit(1) + print(f"→ Skipping desktop source build (--skip-build --source); using dist at {desktop_dir / 'dist'}") + elif packaged_executable is None: + print(f"✗ --skip-build was passed but no packaged desktop app was found at: {desktop_dir / 'release'}") + print(" Pre-build first: cd apps/desktop && npm run pack") + print(" Or drop --skip-build to package automatically.") sys.exit(1) - if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists(): - print("✗ --skip-build requires existing workspace dependencies.") - print(f" Install first: cd {PROJECT_ROOT} && npm ci") - print(" Or drop --skip-build to install dependencies and build automatically.") - sys.exit(1) - print(f"→ Skipping desktop build (--skip-build); using dist at {desktop_dir / 'dist'}") + 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) @@ -6445,15 +6483,28 @@ def cmd_gui(args): print(f" Run manually: cd {PROJECT_ROOT} && npm ci") sys.exit(install_result.returncode or 1) - print("→ Building desktop GUI...") - build_result = subprocess.run([npm, "run", "build"], cwd=desktop_dir, env=env, check=False) + 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(" Run manually: cd apps/desktop && npm run build") + 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) - print("→ Launching Hermes Desktop...") - launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False) + if source_mode: + print("→ Launching Hermes Desktop from source build...") + launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False) + sys.exit(launch_result.returncode) + + if packaged_executable is None: + print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}") + print(" Expected an unpacked Electron app for the current OS.") + sys.exit(1) + + print(f"→ Launching packaged Hermes Desktop: {packaged_executable}") + launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False) sys.exit(launch_result.returncode) @@ -13330,13 +13381,19 @@ Examples: help="Build and launch the native desktop GUI", description=( "Launch the Hermes Electron desktop app. By default this installs " - "workspace Node dependencies, builds apps/desktop inline, then starts Electron." + "workspace Node dependencies, builds the current OS's unpacked " + "Electron app, then launches that packaged artifact." ), ) gui_parser.add_argument( "--skip-build", action="store_true", - help="Skip npm install/build and launch the existing apps/desktop/dist build", + help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release", + ) + gui_parser.add_argument( + "--source", + action="store_true", + help="Launch via `electron .` against apps/desktop/dist instead of the packaged app", ) gui_parser.add_argument( "--fake-boot", diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 842f2468f50..963e4563b2b 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -16,6 +16,7 @@ from hermes_cli import main as cli_main def _ns(**kw): defaults = dict( skip_build=False, + source=False, fake_boot=False, ignore_existing=False, hermes_root=None, @@ -33,26 +34,41 @@ def _make_desktop_tree(tmp_path: Path) -> Path: return root -def test_gui_installs_builds_and_launches_desktop(tmp_path, monkeypatch): +def _make_packaged_executable(root: Path, monkeypatch, platform: str = "darwin") -> Path: + monkeypatch.setattr(cli_main.sys, "platform", platform) + desktop_dir = root / "apps" / "desktop" + if platform == "darwin": + exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes" + elif platform == "win32": + exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" + else: + exe = desktop_dir / "release" / "linux-unpacked" / "hermes" + exe.parent.mkdir(parents=True) + exe.write_text("", encoding="utf-8") + return exe + + +def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) - build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0) - launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 0) + pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 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.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \ + 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()) assert exc.value.code == 0 mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False) - assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"] + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir - assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)] assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir @@ -63,6 +79,7 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch): hermes_root.mkdir() cwd.mkdir() monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) @@ -96,31 +113,27 @@ def test_gui_exits_when_npm_missing(tmp_path, monkeypatch, capsys): assert "npm was not found" in capsys.readouterr().out -def test_gui_skip_build_requires_existing_dist(tmp_path, monkeypatch, capsys): +def test_gui_skip_build_requires_existing_packaged_app(tmp_path, monkeypatch, capsys): root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(cli_main.sys, "platform", "darwin") - with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - pytest.raises(SystemExit) as exc: + with pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(skip_build=True)) assert exc.value.code == 1 - assert "no desktop dist found" in capsys.readouterr().out + assert "no packaged desktop app" in capsys.readouterr().out -def test_gui_skip_build_launches_existing_dist_without_install_or_build(tmp_path, monkeypatch): +def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" - (desktop_dir / "dist").mkdir() - (desktop_dir / "dist" / "index.html").write_text("
", encoding="utf-8") - electron_pkg = root / "node_modules" / "electron" - electron_pkg.mkdir(parents=True) - (electron_pkg / "package.json").write_text("{}", encoding="utf-8") monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) - launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 0) + launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) - with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + with patch("hermes_cli.main.shutil.which", return_value=None), \ patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ pytest.raises(SystemExit) as exc: @@ -129,7 +142,29 @@ def test_gui_skip_build_launches_existing_dist_without_install_or_build(tmp_path assert exc.value.code == 0 mock_install.assert_not_called() mock_run.assert_called_once() - assert mock_run.call_args.args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + assert mock_run.call_args.args[0] == [str(packaged_exe)] + + +def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + desktop_dir = root / "apps" / "desktop" + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0) + launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 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), \ + 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)) + + assert exc.value.code == 0 + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"] + assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir + assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir @pytest.mark.parametrize(