fix(web): handle non-UTF8 Windows console encodings in _build_web_ui

Codex review pointed out that even with the sync-assets fix applied,
_build_web_ui still crashes on a stock Windows console before reaching
npm: Python stdout defaults to cp1252 (or similar) and raises
UnicodeEncodeError when print() hits the arrow/check glyphs used for
status messages (→, ✗, ⚠, ✓). Reproduced locally in PowerShell:

    $ PYTHONIOENCODING=cp1252 python -c "from hermes_cli.main import _build_web_ui; _build_web_ui(Path('web'), fatal=True)"
    UnicodeEncodeError: 'charmap' codec can't encode character '\u2192' ...

The previous PR body claimed "end-to-end verified on Windows 11", but
that was under the venv's default (utf-8) stdout. A plain `py` or
PowerShell invocation would still fail before sync-assets ever ran.

Fix: inner _say() helper that falls back to
  text.encode(sys.stdout.encoding, errors="replace")
when print() raises UnicodeEncodeError. Glyphs degrade to '?' on
ASCII / cp1252 consoles; utf-8 consoles are unaffected. Verified the
full build pipeline runs to completion with PYTHONIOENCODING=cp1252.

Scoped tightly to _build_web_ui (the function this PR already touches);
other call sites in the codebase with the same risk are out of scope.
This commit is contained in:
ioannis 2026-04-21 07:49:15 +01:00 committed by Teknium
parent 0854640537
commit 38ea2a57a5

View file

@ -5681,13 +5681,25 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if not _web_ui_build_needed(web_dir): if not _web_ui_build_needed(web_dir):
return True return True
# Console-encoding-safe print: Windows consoles default to cp1252
# (or similar) and will raise UnicodeEncodeError on arrow / check
# glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print
# in this function through _say() with errors="replace" keeps the
# build path usable on a stock `py -m hermes_cli.main web` invocation.
def _say(text: str) -> None:
try:
print(text)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
npm = shutil.which("npm") npm = shutil.which("npm")
if not npm: if not npm:
if fatal: if fatal:
print("Web UI frontend not built and npm is not available.") _say("Web UI frontend not built and npm is not available.")
print("Install Node.js, then run: cd web && npm install && npm run build") _say("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal return not fatal
print("→ Building web UI...") _say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None: def _relay(result: "subprocess.CompletedProcess") -> None:
"""Print captured npm output so users can see *why* a step failed. """Print captured npm output so users can see *why* a step failed.
@ -5702,17 +5714,17 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
continue continue
text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip()
if text: if text:
print(text) _say(text)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",)) r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
if r1.returncode != 0: if r1.returncode != 0:
print( _say(
f" {'' if fatal else ''} Web UI npm install failed" f" {'' if fatal else ''} Web UI npm install failed"
+ ("" if fatal else " (hermes web will not be available)") + ("" if fatal else " (hermes web will not be available)")
) )
_relay(r1) _relay(r1)
if fatal: if fatal:
print(" Run manually: cd web && npm install && npm run build") _say(" Run manually: cd web && npm install && npm run build")
return False return False
# First attempt # First attempt
r2 = subprocess.run( r2 = subprocess.run(
@ -5747,20 +5759,20 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# A stale UI is far better than no UI for non-interactive callers # A stale UI is far better than no UI for non-interactive callers
# (Windows Scheduled Tasks, CI) — issue #23817. # (Windows Scheduled Tasks, CI) — issue #23817.
if dist_index.exists(): if dist_index.exists():
print(" ⚠ Web UI build failed — serving stale dist as fallback") _say(" ⚠ Web UI build failed — serving stale dist as fallback")
if stderr_tail: if stderr_tail:
print(f" Build error:\n {stderr_tail}") _say(f" Build error:\n {stderr_tail}")
return True return True
print( _say(
f" {'' if fatal else ''} Web UI build failed" f" {'' if fatal else ''} Web UI build failed"
+ ("" if fatal else " (hermes web will not be available)") + ("" if fatal else " (hermes web will not be available)")
) )
_relay(r2) _relay(r2)
if fatal: if fatal:
print(" Run manually: cd web && npm install && npm run build") _say(" Run manually: cd web && npm install && npm run build")
return False return False
print(" ✓ Web UI built") _say(" ✓ Web UI built")
return True return True