tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
677 lines
30 KiB
Python
677 lines
30 KiB
Python
"""Install, hook, service, and container operations."""
|
|
|
|
import getpass
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import importlib.util
|
|
|
|
from tai_pkg.consts import Consts
|
|
from tai_pkg.errors import BackendError
|
|
from tai_pkg.sandbox import Sandbox
|
|
from tai_pkg.telegram import Telegram
|
|
from tai_pkg.telegram import TelegramBot
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["Install"]
|
|
|
|
|
|
class Install:
|
|
"""Install."""
|
|
|
|
@staticmethod
|
|
def run_host(argv, timeout=120):
|
|
try:
|
|
done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout)
|
|
return done.returncode == 0, (done.stdout or "") + (done.stderr or "")
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return False, Text.short_error(exc)
|
|
|
|
@staticmethod
|
|
def venv_available():
|
|
if importlib.util.find_spec("venv") is None:
|
|
return False
|
|
if importlib.util.find_spec("ensurepip") is not None:
|
|
return True
|
|
return shutil.which("pip3") is not None or shutil.which("pip") is not None
|
|
|
|
@staticmethod
|
|
def venv_dir(home):
|
|
return os.path.join(home, "venv")
|
|
|
|
@staticmethod
|
|
def venv_python(home):
|
|
cand = os.path.join(Install.venv_dir(home), "bin", "python")
|
|
if os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
return cand
|
|
return None
|
|
|
|
@staticmethod
|
|
def ensure_venv(home):
|
|
found = Install.venv_python(home)
|
|
if found:
|
|
return True, "kept %s" % found
|
|
if not Install.venv_available():
|
|
return False, "python venv module unavailable"
|
|
try:
|
|
done = subprocess.run([sys.executable, "-m", "venv", Install.venv_dir(home)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=300)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return False, Text.short_error(exc)
|
|
if done.returncode != 0 or not Install.venv_python(home):
|
|
return False, ((done.stderr or "").strip()[:200] or "venv creation failed")
|
|
return True, "created %s" % Install.venv_python(home)
|
|
|
|
@staticmethod
|
|
def service_exec(home, script, *args):
|
|
parts = []
|
|
python = Install.venv_python(home)
|
|
if python:
|
|
parts.append(python)
|
|
parts.append(script)
|
|
parts.extend(args)
|
|
return " ".join(parts)
|
|
|
|
@staticmethod
|
|
def unit_dir(home):
|
|
return os.path.join(os.path.expanduser("~"), ".config", "systemd", "user")
|
|
|
|
@staticmethod
|
|
def service_state(name):
|
|
if not shutil.which("systemctl"):
|
|
return "unknown", "unknown"
|
|
active, _out = Install.run_host(["systemctl", "--user", "is-active", name], timeout=15)
|
|
enabled, _out = Install.run_host(["systemctl", "--user", "is-enabled", name], timeout=15)
|
|
return ("active" if active else "inactive"), ("enabled" if enabled else "disabled")
|
|
|
|
@staticmethod
|
|
def activate_service(name):
|
|
if not shutil.which("systemctl"):
|
|
return "systemctl missing, unit written only"
|
|
Install.run_host(["systemctl", "--user", "daemon-reload"])
|
|
active, _enabled = Install.service_state(name)
|
|
if active == "active":
|
|
ok, out = Install.run_host(["systemctl", "--user", "restart", name])
|
|
return "restarted, changes effective immediately" if ok else "restart failed: " + out.strip()[:150]
|
|
ok, out = Install.run_host(["systemctl", "--user", "enable", "--now", name])
|
|
if ok:
|
|
user = os.environ.get("USER") or getpass.getuser()
|
|
Install.run_host(["loginctl", "enable-linger", user])
|
|
return "enabled and started"
|
|
return "enable failed: " + out.strip()[:150]
|
|
|
|
@staticmethod
|
|
def remove_service_unit(home, name):
|
|
path = os.path.join(Install.unit_dir(home), name)
|
|
if shutil.which("systemctl"):
|
|
Install.run_host(["systemctl", "--user", "disable", "--now", name])
|
|
Install.run_host(["systemctl", "--user", "daemon-reload"])
|
|
if os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
return True, "removed " + path
|
|
except OSError as exc:
|
|
return False, Text.short_error(exc)
|
|
return True, "not present"
|
|
|
|
@staticmethod
|
|
def script_version(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
match = Consts.VERSION_RE.search(handle.read())
|
|
except OSError:
|
|
return None
|
|
return ".".join(match.groups()) if match else None
|
|
|
|
@staticmethod
|
|
def shell_rc_path(shell):
|
|
"""Where each shell's startup file lives. bash/zsh: a dotfile sourced
|
|
for interactive shells only. fish: config.fish, which fish sources on
|
|
every startup (interactive or not, login or not) -- hence the
|
|
`status is-interactive` guard inside FISH_PLEXER_BLOCK rather than
|
|
relying on the file only being read at the right time."""
|
|
home = os.path.expanduser("~")
|
|
if shell == "fish":
|
|
config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
|
|
return os.path.join(config_home, "fish", "config.fish")
|
|
if shell == "zsh":
|
|
return os.path.join(home, ".zshrc")
|
|
return os.path.join(home, ".bashrc")
|
|
|
|
@staticmethod
|
|
def shell_present(shell):
|
|
return shutil.which(shell) is not None
|
|
|
|
@staticmethod
|
|
def _rc_block_present(path, block):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return block in handle.read()
|
|
except OSError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _rc_block_remove(path, mark_begin, mark_end):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
raw = handle.read()
|
|
except OSError:
|
|
return False
|
|
stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL)
|
|
if stripped == raw:
|
|
return False
|
|
backup = path + ".bak-tai"
|
|
if raw and not os.path.exists(backup):
|
|
with open(backup, "w", encoding="utf-8") as handle:
|
|
handle.write(raw)
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(stripped)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _rc_block_upsert(path, mark_begin, mark_end, block):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
raw = handle.read()
|
|
except OSError:
|
|
raw = ""
|
|
if block in raw:
|
|
return False
|
|
backup = path + ".bak-tai"
|
|
if raw and not os.path.exists(backup):
|
|
with open(backup, "w", encoding="utf-8") as handle:
|
|
handle.write(raw)
|
|
stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL)
|
|
if stripped and not stripped.endswith("\n"):
|
|
stripped += "\n"
|
|
parent = os.path.dirname(path)
|
|
if parent and not os.path.isdir(parent):
|
|
os.makedirs(parent, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write(stripped + block)
|
|
return True
|
|
|
|
@staticmethod
|
|
def hook_present(path):
|
|
return Install._rc_block_present(path, Consts.BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_bashrc_block(path):
|
|
return Install._rc_block_remove(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def zsh_hook_present(path):
|
|
return Install._rc_block_present(path, Consts.ZSH_NOTFOUND_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_zsh_hook_block(path):
|
|
return Install._rc_block_remove(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def upsert_zsh_hook_block(path):
|
|
return Install._rc_block_upsert(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END, Consts.ZSH_NOTFOUND_BLOCK)
|
|
|
|
@staticmethod
|
|
def fish_hook_present(path):
|
|
return Install._rc_block_present(path, Consts.FISH_NOTFOUND_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_fish_hook_block(path):
|
|
return Install._rc_block_remove(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def upsert_fish_hook_block(path):
|
|
return Install._rc_block_upsert(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END, Consts.FISH_NOTFOUND_BLOCK)
|
|
|
|
@staticmethod
|
|
def plexer_hook_present(path):
|
|
return Install._rc_block_present(path, Consts.PLEXER_BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_plexer_bashrc_block(path):
|
|
return Install._rc_block_remove(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def upsert_plexer_bashrc_block(path):
|
|
return Install._rc_block_upsert(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END, Consts.PLEXER_BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
# zsh reuses PLEXER_BASHRC_BLOCK byte-for-byte -- zsh is POSIX-enough that
|
|
# the plain `[`/`case "$-"` bash content works unchanged; only the target
|
|
# file differs (~/.zshrc, not ~/.bashrc). No ZSH_PLEXER_BLOCK constant exists
|
|
# on purpose -- there is nothing shell-specific to put in one.
|
|
def zsh_plexer_hook_present(path):
|
|
return Install._rc_block_present(path, Consts.PLEXER_BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_zsh_plexer_block(path):
|
|
return Install._rc_block_remove(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def upsert_zsh_plexer_block(path):
|
|
return Install._rc_block_upsert(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END, Consts.PLEXER_BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
def fish_plexer_hook_present(path):
|
|
return Install._rc_block_present(path, Consts.FISH_PLEXER_BLOCK)
|
|
|
|
@staticmethod
|
|
def remove_fish_plexer_block(path):
|
|
return Install._rc_block_remove(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END)
|
|
|
|
@staticmethod
|
|
def upsert_fish_plexer_block(path):
|
|
return Install._rc_block_upsert(path, Consts.PLEXER_BASHRC_MARK_BEGIN, Consts.PLEXER_BASHRC_MARK_END, Consts.FISH_PLEXER_BLOCK)
|
|
|
|
@staticmethod
|
|
def box_venv_state(engine):
|
|
try:
|
|
probe = Sandbox.box_exec(engine, ["test", "-x", Consts.BOX_PYTHON], timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return "unknown"
|
|
return "venv ready" if probe.returncode == 0 else "no venv (reinstall container to rebuild)"
|
|
|
|
@staticmethod
|
|
def shell_hook_status_line(label, shell, present_fn):
|
|
path = Install.shell_rc_path(shell)
|
|
note = "" if Install.shell_present(shell) else " (%s not found)" % shell
|
|
return "%s: %s in %s%s" % (label, "installed" if present_fn(path) else "not installed", path, note)
|
|
|
|
@staticmethod
|
|
def status_lines(home, store=None):
|
|
lines = []
|
|
binary = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py")
|
|
if os.path.isfile(binary):
|
|
consts_path = os.path.join(os.path.dirname(binary), "tai_pkg", "consts.py")
|
|
lines.append("binary: installed %s (%s, running %s)" % (binary, Install.script_version(consts_path) or "unknown", Consts.VERSION))
|
|
else:
|
|
lines.append("binary: not installed")
|
|
lines.append("bash-hook: %s in ~/.bashrc" % ("installed" if Install.hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed"))
|
|
lines.append(Install.shell_hook_status_line("zsh-hook", "zsh", Install.zsh_hook_present))
|
|
lines.append(Install.shell_hook_status_line("fish-hook", "fish", Install.fish_hook_present))
|
|
lines.append("plexer-hook: %s in ~/.bashrc" % ("installed" if Install.plexer_hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed"))
|
|
lines.append(Install.shell_hook_status_line("zsh-plexer-hook", "zsh", Install.zsh_plexer_hook_present))
|
|
lines.append(Install.shell_hook_status_line("fish-plexer-hook", "fish", Install.fish_plexer_hook_present))
|
|
python = Install.venv_python(home)
|
|
if python:
|
|
lines.append("venv: ready %s" % python)
|
|
elif Install.venv_available():
|
|
lines.append("venv: not created")
|
|
else:
|
|
lines.append("venv: unavailable (no venv module)")
|
|
for name, label in (("tai-scheduler.service", "scheduler-service"), ("tai-telegram.service", "telegram-service")):
|
|
unit = os.path.join(Install.unit_dir(home), name)
|
|
if not os.path.isfile(unit):
|
|
lines.append("%s: not installed" % label)
|
|
continue
|
|
active, enabled = Install.service_state(name)
|
|
lines.append("%s: unit present, %s, %s" % (label, active, enabled))
|
|
engine = Sandbox.container_engine()
|
|
if not engine:
|
|
lines.append("container: no engine (install podman)")
|
|
else:
|
|
state = Sandbox.box_state(engine)
|
|
if state == "running":
|
|
lines.append("container: running, %s" % Install.box_venv_state(engine))
|
|
else:
|
|
lines.append("container: %s" % state)
|
|
lines.append(Install.vault_line(home, store))
|
|
return lines
|
|
|
|
@staticmethod
|
|
def vault_line(home, store=None):
|
|
db_path = os.path.join(home, "memory.db")
|
|
if not os.path.isfile(db_path):
|
|
return "vault: empty, install ops never touch data"
|
|
if store is None:
|
|
return "vault: kept %s, install ops never touch data" % db_path
|
|
try:
|
|
secrets = store.db.execute("SELECT COUNT(*) FROM secrets").fetchone()[0]
|
|
schedules = store.db.execute("SELECT COUNT(*) FROM schedules").fetchone()[0]
|
|
records = store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0]
|
|
except sqlite3.Error:
|
|
return "vault: kept %s, install ops never touch data" % db_path
|
|
return "vault: kept (%d secrets, %d schedules, %d records), install ops never touch data" % (secrets, schedules, records)
|
|
|
|
@staticmethod
|
|
def op_binary(mode, home):
|
|
target = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py")
|
|
pkg_target = os.path.join(os.path.dirname(target), "tai_pkg")
|
|
consts_target = os.path.join(pkg_target, "consts.py")
|
|
src = os.path.abspath(Consts.TAI_SCRIPT)
|
|
src_pkg = os.path.dirname(os.path.abspath(Consts.TAI_CONSTS))
|
|
current = Install.script_version(consts_target) if os.path.isfile(consts_target) else None
|
|
if mode == "install" and current is not None:
|
|
return "binary: already installed %s (running %s)" % (current, Consts.VERSION)
|
|
if mode == "uninstall":
|
|
if not os.path.exists(target) and not os.path.exists(pkg_target):
|
|
return "binary: not present"
|
|
try:
|
|
if os.path.exists(target):
|
|
os.remove(target)
|
|
if os.path.isdir(pkg_target):
|
|
shutil.rmtree(pkg_target)
|
|
return "binary: removed %s" % target
|
|
except OSError as exc:
|
|
return "binary: remove failed: %s" % Text.short_error(exc)
|
|
try:
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
with open(src, "rb") as handle:
|
|
blob = handle.read()
|
|
with open(target, "wb") as handle:
|
|
handle.write(blob)
|
|
os.chmod(target, 0o755)
|
|
if os.path.abspath(src_pkg) != os.path.abspath(pkg_target):
|
|
if os.path.isdir(pkg_target):
|
|
shutil.rmtree(pkg_target)
|
|
shutil.copytree(src_pkg, pkg_target, ignore=shutil.ignore_patterns("__pycache__"))
|
|
except OSError as exc:
|
|
return "binary: copy failed: %s" % Text.short_error(exc)
|
|
if current is None:
|
|
return "binary: installed %s" % Consts.VERSION
|
|
return "binary: refreshed %s -> %s" % (current, Consts.VERSION)
|
|
|
|
@staticmethod
|
|
def op_hook(mode, home):
|
|
path = os.path.join(os.path.expanduser("~"), ".bashrc")
|
|
if mode == "uninstall":
|
|
return "bash-hook: %s" % ("removed" if Install.remove_bashrc_block(path) else "not present")
|
|
if mode == "reinstall":
|
|
Install.remove_bashrc_block(path)
|
|
try:
|
|
changed = Install.upsert_bashrc_block(path)
|
|
except OSError as exc:
|
|
return "bash-hook: write failed: %s" % Text.short_error(exc)
|
|
if changed:
|
|
return "bash-hook: installed, run: source ~/.bashrc"
|
|
return "bash-hook: already present"
|
|
|
|
@staticmethod
|
|
def op_plexer_hook(mode, home):
|
|
path = os.path.join(os.path.expanduser("~"), ".bashrc")
|
|
if mode == "uninstall":
|
|
return "plexer-hook: %s" % ("removed" if Install.remove_plexer_bashrc_block(path) else "not present")
|
|
if mode == "reinstall":
|
|
Install.remove_plexer_bashrc_block(path)
|
|
try:
|
|
changed = Install.upsert_plexer_bashrc_block(path)
|
|
except OSError as exc:
|
|
return "plexer-hook: write failed: %s" % Text.short_error(exc)
|
|
if changed:
|
|
return "plexer-hook: installed, run: source ~/.bashrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)"
|
|
return "plexer-hook: already present"
|
|
|
|
@staticmethod
|
|
def op_shell_hook(mode, shell, label, upsert_fn, remove_fn, source_hint):
|
|
"""Shared body for the zsh/fish hook variants -- mirrors op_hook/op_plexer_hook
|
|
exactly, plus one addition: install/upgrade skip loudly (never write a dangling
|
|
hook into a config file for a shell that isn't even on this machine) when the
|
|
shell binary isn't found. uninstall/reinstall/status still act on the file
|
|
regardless, so a hook installed before a shell was later removed still cleans up."""
|
|
path = Install.shell_rc_path(shell)
|
|
if mode in ("install", "upgrade") and not Install.shell_present(shell):
|
|
return "%s: %s not found, skipped" % (label, shell)
|
|
if mode == "uninstall":
|
|
return "%s: %s" % (label, "removed" if remove_fn(path) else "not present")
|
|
if mode == "reinstall":
|
|
remove_fn(path)
|
|
try:
|
|
changed = upsert_fn(path)
|
|
except OSError as exc:
|
|
return "%s: write failed: %s" % (label, Text.short_error(exc))
|
|
if changed:
|
|
return "%s: installed, run: %s" % (label, source_hint)
|
|
return "%s: already present" % label
|
|
|
|
@staticmethod
|
|
def op_zsh_hook(mode, home):
|
|
return Install.op_shell_hook(mode, "zsh", "zsh-hook", Install.upsert_zsh_hook_block, Install.remove_zsh_hook_block, "source ~/.zshrc")
|
|
|
|
@staticmethod
|
|
def op_fish_hook(mode, home):
|
|
return Install.op_shell_hook(mode, "fish", "fish-hook", Install.upsert_fish_hook_block, Install.remove_fish_hook_block, "open a new fish shell")
|
|
|
|
@staticmethod
|
|
def op_zsh_plexer_hook(mode, home):
|
|
return Install.op_shell_hook(
|
|
mode, "zsh", "zsh-plexer-hook", Install.upsert_zsh_plexer_block, Install.remove_zsh_plexer_block,
|
|
"source ~/.zshrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)",
|
|
)
|
|
|
|
@staticmethod
|
|
def op_fish_plexer_hook(mode, home):
|
|
return Install.op_shell_hook(
|
|
mode, "fish", "fish-plexer-hook", Install.upsert_fish_plexer_block, Install.remove_fish_plexer_block,
|
|
"open a new fish shell (new interactive shells auto-wrap; existing ones are unaffected until restarted)",
|
|
)
|
|
|
|
@staticmethod
|
|
def op_venv(mode, home):
|
|
if mode == "uninstall":
|
|
folder = Install.venv_dir(home)
|
|
if not os.path.isdir(folder):
|
|
return "venv: not present"
|
|
try:
|
|
shutil.rmtree(folder)
|
|
return "venv: removed %s" % folder
|
|
except OSError as exc:
|
|
return "venv: remove failed: %s" % Text.short_error(exc)
|
|
if mode == "reinstall" and os.path.isdir(Install.venv_dir(home)):
|
|
try:
|
|
shutil.rmtree(Install.venv_dir(home))
|
|
except OSError as exc:
|
|
return "venv: rebuild failed: %s" % Text.short_error(exc)
|
|
ok, detail = Install.ensure_venv(home)
|
|
return "venv: %s" % detail
|
|
|
|
@staticmethod
|
|
def op_service(mode, home, name, label, body):
|
|
unit = os.path.join(Install.unit_dir(home), name)
|
|
if mode == "uninstall":
|
|
_done, detail = Install.remove_service_unit(home, name)
|
|
return "%s: %s" % (label, detail)
|
|
existed = os.path.isfile(unit)
|
|
if mode == "install" and existed:
|
|
active, enabled = Install.service_state(name)
|
|
return "%s: already installed (%s, %s)" % (label, active, enabled)
|
|
if mode == "reinstall" and shutil.which("systemctl"):
|
|
Install.run_host(["systemctl", "--user", "disable", "--now", name])
|
|
try:
|
|
os.makedirs(os.path.dirname(unit), exist_ok=True)
|
|
with open(unit, "w", encoding="utf-8") as handle:
|
|
handle.write(body)
|
|
except OSError as exc:
|
|
return "%s: unit write failed: %s" % (label, Text.short_error(exc))
|
|
return "%s: unit %s, %s" % (label, "rewritten" if existed else "written", Install.activate_service(name))
|
|
|
|
@staticmethod
|
|
def op_scheduler_service(mode, home):
|
|
body = Consts.SCHEDULER_UNIT % Install.service_exec(home, os.path.abspath(Consts.TAI_SCRIPT))
|
|
return Install.op_service(mode, home, "tai-scheduler.service", "scheduler-service", body)
|
|
|
|
@staticmethod
|
|
def op_telegram_service(mode, home):
|
|
if mode == "uninstall":
|
|
_done, detail = Install.remove_service_unit(home, "tai-telegram.service")
|
|
return "telegram-service: %s" % detail
|
|
unit = os.path.join(Install.unit_dir(home), "tai-telegram.service")
|
|
if mode == "install" and os.path.isfile(unit):
|
|
active, enabled = Install.service_state("tai-telegram.service")
|
|
return "telegram-service: already installed (%s, %s)" % (active, enabled)
|
|
token = os.environ.get("TELEGRAM_BOT_TOKEN") or TelegramBot.load_telegram_token(home)
|
|
if not token:
|
|
return "telegram-service: needs TELEGRAM_BOT_TOKEN in the environment"
|
|
try:
|
|
me = Telegram(token).call("getMe", {})
|
|
except (OSError, ValueError) as exc:
|
|
return "telegram-service: token rejected: %s" % Text.short_error(exc)
|
|
env_path = TelegramBot.telegram_env_path(home)
|
|
try:
|
|
with open(env_path, "w", encoding="utf-8") as handle:
|
|
handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token)
|
|
os.chmod(env_path, 0o600)
|
|
except OSError as exc:
|
|
return "telegram-service: env write failed: %s" % Text.short_error(exc)
|
|
body = Consts.TELEGRAM_UNIT % (Install.service_exec(home, os.path.abspath(Consts.TAI_SCRIPT)), env_path)
|
|
if mode == "reinstall" and shutil.which("systemctl"):
|
|
Install.run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"])
|
|
try:
|
|
os.makedirs(os.path.dirname(unit), exist_ok=True)
|
|
with open(unit, "w", encoding="utf-8") as handle:
|
|
handle.write(body)
|
|
except OSError as exc:
|
|
return "telegram-service: unit write failed: %s" % Text.short_error(exc)
|
|
who = (me.get("result") or {}).get("username", "?")
|
|
return "telegram-service: @%s, %s" % (who, Install.activate_service("tai-telegram.service"))
|
|
|
|
@staticmethod
|
|
def op_container(mode, _home):
|
|
engine = Sandbox.container_engine()
|
|
if not engine:
|
|
return "container: no engine (install podman)"
|
|
if mode == "uninstall":
|
|
Install.run_host([engine, "rm", "-f", Consts.BOX_NAME])
|
|
ok, _out = Install.run_host([engine, "rmi", "-f", Consts.BOX_IMAGE])
|
|
return "container: removed%s" % (", image purged" if ok else "")
|
|
if mode == "reinstall":
|
|
Install.run_host([engine, "rm", "-f", Consts.BOX_NAME])
|
|
Install.run_host([engine, "rmi", "-f", Consts.BOX_IMAGE])
|
|
try:
|
|
engine = Sandbox.ensure_box()
|
|
except BackendError as exc:
|
|
return "container: failed: %s" % exc
|
|
state = Sandbox.box_state(engine)
|
|
detail = ", %s" % Install.box_venv_state(engine) if state == "running" else ""
|
|
if mode == "reinstall":
|
|
return "container: rebuilt and %s%s" % (state, detail)
|
|
if mode == "install" and state == "running":
|
|
return "container: already running%s" % detail
|
|
return "container: %s%s" % (state, detail)
|
|
|
|
@staticmethod
|
|
def install_report(action, targets, home, store=None):
|
|
if action == "status":
|
|
return "\n".join(Install.status_lines(home, store))
|
|
lines = []
|
|
for target in targets:
|
|
if target == "binary":
|
|
lines.append(Install.op_binary(action, home))
|
|
elif target == "bash-hook":
|
|
lines.append(Install.op_hook(action, home))
|
|
elif target == "zsh-hook":
|
|
lines.append(Install.op_zsh_hook(action, home))
|
|
elif target == "fish-hook":
|
|
lines.append(Install.op_fish_hook(action, home))
|
|
elif target == "plexer-hook":
|
|
lines.append(Install.op_plexer_hook(action, home))
|
|
elif target == "zsh-plexer-hook":
|
|
lines.append(Install.op_zsh_plexer_hook(action, home))
|
|
elif target == "fish-plexer-hook":
|
|
lines.append(Install.op_fish_plexer_hook(action, home))
|
|
elif target == "venv":
|
|
lines.append(Install.op_venv(action, home))
|
|
elif target == "scheduler-service":
|
|
lines.append(Install.op_scheduler_service(action, home))
|
|
elif target == "telegram-service":
|
|
lines.append(Install.op_telegram_service(action, home))
|
|
elif target == "container":
|
|
lines.append(Install.op_container(action, home))
|
|
lines.append(Install.vault_line(home, store))
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def install_telegram():
|
|
token = os.environ.get("TELEGRAM_BOT_TOKEN") or ""
|
|
if token:
|
|
print("using TELEGRAM_BOT_TOKEN from environment")
|
|
else:
|
|
try:
|
|
token = getpass.getpass("Telegram bot token: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\naborted")
|
|
return 1
|
|
if not token:
|
|
print("empty token, aborted")
|
|
return 1
|
|
try:
|
|
me = Telegram(token).call("getMe", {})
|
|
except (OSError, ValueError) as exc:
|
|
print("token rejected: %s" % Text.short_error(exc))
|
|
return 1
|
|
print("token ok for @%s" % (me.get("result") or {}).get("username", "?"))
|
|
home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai")
|
|
os.makedirs(home, exist_ok=True)
|
|
env_path = TelegramBot.telegram_env_path(home)
|
|
with open(env_path, "w", encoding="utf-8") as handle:
|
|
handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token)
|
|
try:
|
|
os.chmod(env_path, 0o600)
|
|
except OSError:
|
|
pass
|
|
print("wrote %s" % env_path)
|
|
try:
|
|
Sandbox.ensure_box()
|
|
except BackendError as exc:
|
|
print("container failed: %s" % exc)
|
|
return 1
|
|
unit_folder = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user")
|
|
os.makedirs(unit_folder, exist_ok=True)
|
|
unit_path = os.path.join(unit_folder, "tai-telegram.service")
|
|
with open(unit_path, "w", encoding="utf-8") as handle:
|
|
handle.write(Consts.TELEGRAM_UNIT % (Install.service_exec(home, os.path.abspath(Consts.TAI_SCRIPT)), env_path))
|
|
print("wrote %s" % unit_path)
|
|
print("service: %s" % Install.activate_service("tai-telegram.service"))
|
|
return 0
|
|
|
|
@staticmethod
|
|
def uninstall_telegram():
|
|
unit_path = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user", "tai-telegram.service")
|
|
if shutil.which("systemctl"):
|
|
Install.run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"])
|
|
if os.path.exists(unit_path):
|
|
try:
|
|
os.remove(unit_path)
|
|
print("removed %s" % unit_path)
|
|
except OSError as exc:
|
|
print("unit remove failed: %s" % Text.short_error(exc))
|
|
else:
|
|
print("no service unit found")
|
|
if shutil.which("systemctl"):
|
|
Install.run_host(["systemctl", "--user", "daemon-reload"])
|
|
engine = Sandbox.container_engine()
|
|
if not engine:
|
|
print("no container engine found")
|
|
return 0
|
|
ok, _out = Install.run_host([engine, "rm", "-f", Consts.BOX_NAME])
|
|
print("container remove: %s" % ("ok" if ok else "not present"))
|
|
ok, _out = Install.run_host([engine, "rmi", "-f", Consts.BOX_IMAGE])
|
|
print("image purge: %s" % ("ok" if ok else "not present"))
|
|
return 0
|
|
|
|
@staticmethod
|
|
def upsert_bashrc_block(path):
|
|
return Install._rc_block_upsert(path, Consts.BASHRC_MARK_BEGIN, Consts.BASHRC_MARK_END, Consts.BASHRC_BLOCK)
|
|
|
|
@staticmethod
|
|
def install_self():
|
|
home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai")
|
|
print(Install.op_binary("upgrade", home))
|
|
print(Install.op_hook("install", home))
|
|
# bash-hook always goes in, unconditionally, for back-compat -- it's harmless clutter
|
|
# in ~/.bashrc for a zsh/fish user who never touches bash. $SHELL additionally gets its
|
|
# own matching hook so the one-shot CLI bootstrap (--install) actually works out of the
|
|
# box on a non-bash login shell, not just when the user later runs /install zsh-hook.
|
|
login_shell = os.path.basename(os.environ.get("SHELL") or "")
|
|
rc = "~/.bashrc"
|
|
if login_shell == "zsh":
|
|
print(Install.op_zsh_hook("install", home))
|
|
rc = "~/.zshrc"
|
|
elif login_shell == "fish":
|
|
print(Install.op_fish_hook("install", home))
|
|
rc = "~/.config/fish/config.fish"
|
|
print(Install.op_venv("install", home))
|
|
print("restart your shell or run: source %s" % rc)
|
|
return 0
|