feat(desktop): launch packaged gui builds by default

This commit is contained in:
Brooklyn Nicholson 2026-05-21 21:27:55 -05:00
parent 17264cc147
commit 82e45ab428
2 changed files with 134 additions and 42 deletions

View file

@ -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",

View file

@ -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("<div></div>", 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(