fix(cli): scope TUI npm-install check to the ui-tui workspace closure

_tui_need_npm_install compared the full multi-workspace root
package-lock.json against the hidden .package-lock.json, but the launch
install is scoped with npm install --workspace ui-tui and only writes the
ui-tui dependency closure. Every dep belonging solely to another workspace
(apps/desktop, web, ...) was therefore reported as missing, so the check
returned True and printed "Installing TUI dependencies..." on every launch.

Restrict the comparison to the ui-tui workspace's dependency closure,
computed from the root lock's packages map (following npm's node-resolution
walk and workspace symlinks). Standalone / own-lockfile layouts and any
case where the workspace can't be located fall back to the full comparison,
so drift on a genuine ui-tui dependency is still detected.

Fixes #66978
This commit is contained in:
PRATHAMESH75
2026-07-18 21:53:23 +05:30
committed by Teknium
parent 02c7ae956e
commit 0c47cd5260
2 changed files with 492 additions and 0 deletions
+83
View File
@@ -2056,6 +2056,71 @@ def _termux_workspace_install_context(
return ws_root, tuple(workspace_args)
def _npm_lock_workspace_closure(packages: dict, start: str) -> Optional[set]:
"""Package-map keys reachable from workspace ``start`` via npm resolution.
Returns ``None`` when ``start`` is absent from *packages* so callers fall
back to the full-lockfile comparison.
The launch install is scoped with ``npm install --workspace ui-tui`` (see
``_make_tui_argv``), so only the ui-tui workspace's dependency closure is
written to the hidden ``.package-lock.json``. The shared root
``package-lock.json`` additionally lists every *other* workspace's deps
(``apps/desktop``, ``web``, …); comparing the two in full reports those
unrelated packages as "missing" and reinstalls on every launch (#66978).
Keys follow npm's v3 ``packages`` map (``""`` root, ``ui-tui`` /
``apps/desktop`` workspace members, ``node_modules/<name>`` hoisted deps,
``<dir>/node_modules/<name>`` nested deps). Dependency names resolve to a
key by walking up ``node_modules`` ancestors, mirroring node resolution, and
workspace symlinks (``link: true``) are followed to their real entry so a
linked workspace's own deps join the closure.
"""
if start not in packages:
return None
def resolve(from_key: str, dep: str) -> Optional[str]:
base = from_key
while True:
prefix = f"{base}/" if base else ""
candidate = f"{prefix}node_modules/{dep}"
if candidate in packages:
return candidate
if not base:
return None
base = base.rsplit("/", 1)[0] if "/" in base else ""
seen: set = set()
stack = [start]
while stack:
key = stack.pop()
if key in seen:
continue
seen.add(key)
entry = packages.get(key)
if not isinstance(entry, dict):
continue
# Workspace symlink (e.g. node_modules/@hermes/ink → ui-tui/packages/…):
# follow to the real package entry so its dependencies join the closure.
resolved = entry.get("resolved")
if entry.get("link") and isinstance(resolved, str) and resolved in packages:
stack.append(resolved)
# devDependencies are installed for the workspace being scoped (ui-tui's
# build toolchain), but not for transitive deps.
fields = ["dependencies", "optionalDependencies", "peerDependencies"]
if key == start:
fields.append("devDependencies")
for field in fields:
deps = entry.get(field)
if not isinstance(deps, dict):
continue
for dep in deps:
target = resolve(key, dep)
if target is not None:
stack.append(target)
return seen
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@@ -2117,10 +2182,28 @@ def _tui_need_npm_install(root: Path) -> bool:
def comparable(pkg: dict) -> dict:
return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
# In a shared workspace checkout the launch install is scoped to the ui-tui
# workspace, so only its dependency closure lands in the hidden lock. Limit
# the comparison to that closure so unrelated workspace deps (apps/desktop,
# web, …) don't force a reinstall every launch (#66978). Standalone /
# own-lockfile layouts (ws_root == root) do a full install, so keep the full
# comparison; a missing/unlocatable workspace falls back to it too.
closure: Optional[set] = None
if ws_root != root:
try:
workspace_key = root.relative_to(ws_root).as_posix()
except ValueError:
workspace_key = ""
if workspace_key:
closure = _npm_lock_workspace_closure(wanted, workspace_key)
for name, pkg in wanted.items():
if not name:
continue
if closure is not None and name not in closure:
continue
if not isinstance(pkg, dict):
continue
+409
View File
@@ -57,6 +57,415 @@ def test_make_tui_argv_uses_bundled_tui_when_workspace_missing(
runnable bundled TUI on disk. The bundled shortcut must succeed without
ever touching the (missing) ui-tui workspace or git.
"""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
"""The peer-drop tolerance must not mask a real version skew."""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
os.utime(tmp_path / "package-lock.json", (100, 100))
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
assert main_mod._tui_need_npm_install(tmp_path) is False
# ── workspace-scoped comparison (#66978) ────────────────────────────
#
# In a shared workspace checkout the launch install is scoped to the ui-tui
# workspace, so only its dependency closure lands in the hidden lock while the
# root lock lists every other workspace's deps too. The comparison must ignore
# those unrelated packages instead of reinstalling on every launch.
def _write_ws(root: Path, ws_lock: str, hidden_lock: str) -> Path:
"""Lay out a workspace root + ui-tui member and return the ui-tui dir.
``@hermes/ink`` and the marker live at the workspace root (hoisted);
``ui-tui/`` has no lockfile of its own so ``_workspace_root`` treats the
parent as the workspace root and the launch scopes to ``--workspace ui-tui``.
"""
(root / "package-lock.json").write_text(ws_lock)
_touch_ink(root)
(root / "node_modules" / ".package-lock.json").write_text(hidden_lock)
tui_dir = root / "ui-tui"
tui_dir.mkdir(parents=True, exist_ok=True)
# package.json (and no own lockfile) is what makes _workspace_root treat the
# parent as the workspace root and the launch scope to --workspace ui-tui.
(tui_dir / "package.json").write_text('{"name":"hermes-tui"}')
return tui_dir
def test_no_install_when_only_other_workspace_deps_missing(tmp_path: Path, main_mod) -> None:
"""Deps that belong to apps/desktop / web (never installed by the ui-tui
scoped install) must not trigger a reinstall on every launch (#66978)."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"},'
'"apps/desktop":{"dependencies":{"desktop-only":"1.0.0"}},'
'"node_modules/desktop-only":{"version":"1.0.0"},'
'"apps/desktop/node_modules/nested":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_mod._tui_need_npm_install(tui_dir) is False
def test_need_install_when_ui_tui_dep_missing_in_workspace_layout(tmp_path: Path, main_mod) -> None:
"""A genuinely missing ui-tui dependency is still caught after scoping."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"},'
'"node_modules/bar":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_mod._tui_need_npm_install(tui_dir) is True
def test_need_install_when_linked_workspace_dep_missing(tmp_path: Path, main_mod) -> None:
"""The closure follows workspace symlinks (@hermes/ink → ui-tui/packages/…)
so a linked workspace's own missing dep triggers a reinstall."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}},'
'"node_modules/inkdep":{"version":"1.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}}'
"}}",
)
assert main_mod._tui_need_npm_install(tui_dir) is True
def test_need_install_when_closure_package_version_drifts(tmp_path: Path, main_mod) -> None:
"""Version drift on an in-closure package still forces a reinstall."""
tui_dir = _write_ws(
tmp_path,
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
'"node_modules/foo":{"version":"2.0.0"}'
"}}",
'{"packages":{'
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
'"node_modules/foo":{"version":"1.0.0"}'
"}}",
)
assert main_mod._tui_need_npm_install(tui_dir) is True
def test_workspace_closure_includes_dev_deps_of_scoped_workspace(main_mod) -> None:
"""ui-tui's devDependencies (esbuild/typescript build toolchain) are part of
the closure; a transitive package's devDependencies are not."""
packages = {
"ui-tui": {
"dependencies": {"foo": "1"},
"devDependencies": {"esbuild": "1"},
},
"node_modules/foo": {"devDependencies": {"foo-dev-only": "1"}},
"node_modules/esbuild": {},
"node_modules/foo-dev-only": {},
}
closure = main_mod._npm_lock_workspace_closure(packages, "ui-tui")
assert "node_modules/esbuild" in closure
assert "node_modules/foo-dev-only" not in closure
def test_workspace_closure_returns_none_when_start_absent(main_mod) -> None:
"""Missing workspace key → None so the caller falls back to full compare."""
assert main_mod._npm_lock_workspace_closure({"node_modules/foo": {}}, "ui-tui") is None
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
_touch_tui_entry(tmp_path)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
assert main_mod._tui_need_rebuild(tmp_path) is True
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "entry.tsx").write_text("console.log('src')")
os.utime(src / "entry.tsx", (100, 100))
os.utime(tmp_path / "dist" / "entry.js", (200, 200))
assert main_mod._tui_need_rebuild(tmp_path) is False
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "entry.tsx").write_text("console.log('src')")
os.utime(tmp_path / "dist" / "entry.js", (100, 100))
os.utime(src / "entry.tsx", (200, 200))
assert main_mod._tui_need_rebuild(tmp_path) is True
def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
def fail_run(*_args, **_kwargs):
raise AssertionError("fresh Termux TUI launch must not rebuild")
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path
def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
def fail_run(*_args, **_kwargs):
raise AssertionError("fresh Termux TUI launch must not run npm")
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path
def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
tmp_path: Path, main_mod, monkeypatch
) -> None:
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
ink_dir = tui_dir / "packages" / "hermes-ink"
ink_dir.mkdir(parents=True)
(ink_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
assert install_cmd[:7] == [
"/bin/npm",
"install",
"--workspace",
"ui-tui",
"--workspace",
"ui-tui/packages/hermes-ink",
"--include-workspace-root=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
_assert_utf8_replace_capture(calls[0][1])
_assert_utf8_replace_capture(calls[1][1])
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
assert calls[0][0][0] == [
"/bin/npm",
"install",
"--workspace",
"ui-tui",
"--include=dev",
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
]
assert calls[0][1]["cwd"] == str(tmp_path)
_assert_utf8_replace_capture(calls[0][1])
_assert_utf8_replace_capture(calls[1][1])
def test_make_tui_argv_npm_install_forces_include_dev(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""The TUI-launch npm install must force --include=dev: ui-tui's build
toolchain (esbuild, typescript) lives in devDependencies, and an inherited
NODE_ENV=production (container shells; a parent TUI sets it on its own
subprocess env) or an npm `omit=dev` config would silently skip them,
breaking the TUI build with `tsc`/`esbuild: command not found."""
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("NODE_ENV", "production")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
assert install_cmd[:2] == ["/bin/npm", "install"]
assert "--include=dev" in install_cmd
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
_assert_utf8_replace_capture(calls[0][1])
def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace(
tmp_path: Path, main_mod, monkeypatch
) -> None:
ink_dir = tmp_path / "packages" / "hermes-ink"
ink_dir.mkdir(parents=True)
tsx = tmp_path / "node_modules" / ".bin" / "tsx"
tsx.parent.mkdir(parents=True)
tsx.write_text("")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tmp_path
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
assert calls[0][1]["cwd"] == str(ink_dir)
_assert_utf8_replace_capture(calls[0][1])
def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable(
tmp_path: Path, main_mod, monkeypatch, capsys
) -> None:
"""Missing ui-tui + no git checkout → clean error, never touches node/npm."""
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None)