2026-07-14 11:27:49 +02:00
|
|
|
"""Thin wrapper around subprocess for all external commands.
|
|
|
|
|
|
|
|
|
|
Every module calls external tools through this module so tests can
|
|
|
|
|
replace ``run``/``output``/``succeeds``/``pipeline`` with fakes.
|
|
|
|
|
|
|
|
|
|
Missing binaries never surface as tracebacks: actions (``check=True``,
|
|
|
|
|
``pipeline``) abort with a clear LimError, tolerated calls
|
|
|
|
|
(``check=False``, ``succeeds``) degrade to a warning plus shell-style
|
|
|
|
|
exit code 127.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import shlex
|
|
|
|
|
import subprocess
|
|
|
|
|
|
|
|
|
|
from lim import ui
|
|
|
|
|
from lim.errors import LimError
|
|
|
|
|
|
|
|
|
|
COMMAND_NOT_FOUND = 127
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _with_sudo(cmd: list[str], *, sudo: bool) -> list[str]:
|
|
|
|
|
parts = [str(part) for part in cmd]
|
|
|
|
|
if sudo and os.geteuid() != 0:
|
|
|
|
|
return ["sudo", *parts]
|
|
|
|
|
return parts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _not_found_message(cmd: list[str]) -> str:
|
|
|
|
|
return f"Command not found: {cmd[0]} — please install it."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run(
|
|
|
|
|
cmd: list[str],
|
|
|
|
|
*,
|
|
|
|
|
sudo: bool = False,
|
|
|
|
|
input_text: str | None = None,
|
|
|
|
|
check: bool = True,
|
|
|
|
|
error_msg: str | None = None,
|
|
|
|
|
) -> subprocess.CompletedProcess:
|
|
|
|
|
"""Run a command inheriting stdio so progress output stays visible."""
|
|
|
|
|
cmd = _with_sudo(cmd, sudo=sudo)
|
|
|
|
|
ui.info(f"Running: {shlex.join(cmd)}")
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(cmd, input=input_text, text=True, check=False)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
if check:
|
|
|
|
|
raise LimError(error_msg or _not_found_message(cmd)) from None
|
|
|
|
|
ui.warning(_not_found_message(cmd))
|
|
|
|
|
return subprocess.CompletedProcess(cmd, COMMAND_NOT_FOUND)
|
|
|
|
|
if check and result.returncode != 0:
|
|
|
|
|
raise LimError(
|
2026-07-22 17:47:32 +02:00
|
|
|
error_msg or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
|
2026-07-14 11:27:49 +02:00
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def output(
|
|
|
|
|
cmd: list[str],
|
|
|
|
|
*,
|
|
|
|
|
sudo: bool = False,
|
|
|
|
|
check: bool = True,
|
|
|
|
|
error_msg: str | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Run a command and return its stripped stdout."""
|
|
|
|
|
cmd = _with_sudo(cmd, sudo=sudo)
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
if check:
|
|
|
|
|
raise LimError(error_msg or _not_found_message(cmd)) from None
|
|
|
|
|
ui.warning(_not_found_message(cmd))
|
|
|
|
|
return ""
|
|
|
|
|
if check and result.returncode != 0:
|
|
|
|
|
raise LimError(
|
|
|
|
|
error_msg
|
|
|
|
|
or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
|
|
|
|
|
+ (f"\n{result.stderr.strip()}" if result.stderr else "")
|
|
|
|
|
)
|
|
|
|
|
return result.stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def succeeds(cmd: list[str], *, sudo: bool = False) -> bool:
|
|
|
|
|
"""Run a command silently, report whether it exited with 0."""
|
|
|
|
|
cmd = _with_sudo(cmd, sudo=sudo)
|
|
|
|
|
try:
|
|
|
|
|
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
ui.warning(_not_found_message(cmd))
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pipeline(
|
|
|
|
|
*cmds: list[str],
|
|
|
|
|
sudo_last: bool = False,
|
|
|
|
|
error_msg: str | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Run commands as a shell-style pipeline (cmd1 | cmd2 | ...)."""
|
|
|
|
|
prepared = [
|
2026-07-22 17:47:32 +02:00
|
|
|
_with_sudo(cmd, sudo=sudo_last and index == len(cmds) - 1) for index, cmd in enumerate(cmds)
|
2026-07-14 11:27:49 +02:00
|
|
|
]
|
|
|
|
|
pretty = " | ".join(shlex.join(cmd) for cmd in prepared)
|
|
|
|
|
ui.info(f"Running: {pretty}")
|
|
|
|
|
processes: list[subprocess.Popen] = []
|
|
|
|
|
previous_stdout = None
|
|
|
|
|
try:
|
|
|
|
|
for index, cmd in enumerate(prepared):
|
|
|
|
|
last = index == len(prepared) - 1
|
|
|
|
|
process = subprocess.Popen(
|
|
|
|
|
cmd,
|
|
|
|
|
stdin=previous_stdout,
|
|
|
|
|
stdout=None if last else subprocess.PIPE,
|
|
|
|
|
)
|
|
|
|
|
if previous_stdout is not None:
|
|
|
|
|
previous_stdout.close()
|
|
|
|
|
previous_stdout = process.stdout
|
|
|
|
|
processes.append(process)
|
|
|
|
|
except FileNotFoundError as exc:
|
|
|
|
|
if previous_stdout is not None:
|
|
|
|
|
previous_stdout.close()
|
|
|
|
|
for process in processes:
|
|
|
|
|
process.kill()
|
|
|
|
|
process.wait()
|
|
|
|
|
raise LimError(f"Command not found: {exc.filename} — please install it.") from None
|
|
|
|
|
for process in processes:
|
|
|
|
|
process.wait()
|
|
|
|
|
if any(process.returncode != 0 for process in processes):
|
|
|
|
|
raise LimError(error_msg or f"Pipeline failed: {pretty}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sync_disks() -> None:
|
|
|
|
|
run(["sync"])
|