Ship the portable X11 bundle lifecycle
Some checks are pending
ci / test-and-build (push) Waiting to run
Some checks are pending
ci / test-and-build (push) Waiting to run
Implement milestone 2 around a portable X11 release bundle instead of\nkeeping distro packages as the only end-user path.\n\nAdd make/package scripts plus a portable installer helper that builds the\ntarball, creates a user-scoped venv install, manages the user service, handles\nupgrade rollback, and supports uninstall with optional purge.\n\nFlip the end-user docs to the portable bundle, add a dedicated install guide\nand validation matrix, and leave the roadmap milestone open only for the\nremaining manual distro validation evidence.\n\nValidation: python3 -m py_compile src/*.py packaging/portable/portable_installer.py tests/test_portable_bundle.py; PYTHONPATH=src python3 -m unittest tests.test_portable_bundle; PYTHONPATH=src python3 -m unittest tests.test_aman_cli tests.test_diagnostics tests.test_portable_bundle; PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py'
This commit is contained in:
parent
511fab683a
commit
a3368056ff
15 changed files with 1372 additions and 45 deletions
5
packaging/portable/install.sh
Executable file
5
packaging/portable/install.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "${SCRIPT_DIR}/portable_installer.py" install --bundle-dir "${SCRIPT_DIR}" "$@"
|
||||
578
packaging/portable/portable_installer.py
Executable file
578
packaging/portable/portable_installer.py
Executable file
|
|
@ -0,0 +1,578 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_NAME = "aman"
|
||||
INSTALL_KIND = "portable"
|
||||
SERVICE_NAME = "aman"
|
||||
MANAGED_MARKER = "# managed by aman portable installer"
|
||||
SUPPORTED_PYTHON_TAGS = ("cp310", "cp311", "cp312")
|
||||
DEFAULT_ARCHITECTURE = "x86_64"
|
||||
DEFAULT_SMOKE_CHECK_CODE = textwrap.dedent(
|
||||
"""
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import AppIndicator3, Gtk
|
||||
import Xlib
|
||||
import sounddevice
|
||||
"""
|
||||
).strip()
|
||||
DEFAULT_RUNTIME_DEPENDENCY_HINT = (
|
||||
"Install the documented GTK, AppIndicator, PyGObject, python-xlib, and "
|
||||
"PortAudio runtime dependencies for your distro, then rerun install.sh."
|
||||
)
|
||||
|
||||
|
||||
class PortableInstallError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallPaths:
|
||||
home: Path
|
||||
share_root: Path
|
||||
current_link: Path
|
||||
state_path: Path
|
||||
bin_dir: Path
|
||||
shim_path: Path
|
||||
systemd_dir: Path
|
||||
service_path: Path
|
||||
config_dir: Path
|
||||
cache_dir: Path
|
||||
|
||||
@classmethod
|
||||
def detect(cls) -> "InstallPaths":
|
||||
home = Path.home()
|
||||
share_root = home / ".local" / "share" / APP_NAME
|
||||
return cls(
|
||||
home=home,
|
||||
share_root=share_root,
|
||||
current_link=share_root / "current",
|
||||
state_path=share_root / "install-state.json",
|
||||
bin_dir=home / ".local" / "bin",
|
||||
shim_path=home / ".local" / "bin" / APP_NAME,
|
||||
systemd_dir=home / ".config" / "systemd" / "user",
|
||||
service_path=home / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service",
|
||||
config_dir=home / ".config" / APP_NAME,
|
||||
cache_dir=home / ".cache" / APP_NAME,
|
||||
)
|
||||
|
||||
def as_serializable(self) -> dict[str, str]:
|
||||
return {
|
||||
"share_root": str(self.share_root),
|
||||
"current_link": str(self.current_link),
|
||||
"state_path": str(self.state_path),
|
||||
"shim_path": str(self.shim_path),
|
||||
"service_path": str(self.service_path),
|
||||
"config_dir": str(self.config_dir),
|
||||
"cache_dir": str(self.cache_dir),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
app_name: str
|
||||
version: str
|
||||
architecture: str
|
||||
supported_python_tags: list[str]
|
||||
wheelhouse_dirs: list[str]
|
||||
managed_paths: dict[str, str]
|
||||
smoke_check_code: str
|
||||
runtime_dependency_hint: str
|
||||
bundle_format_version: int = 1
|
||||
|
||||
@classmethod
|
||||
def default(cls, version: str) -> "Manifest":
|
||||
return cls(
|
||||
app_name=APP_NAME,
|
||||
version=version,
|
||||
architecture=DEFAULT_ARCHITECTURE,
|
||||
supported_python_tags=list(SUPPORTED_PYTHON_TAGS),
|
||||
wheelhouse_dirs=[
|
||||
"wheelhouse/common",
|
||||
"wheelhouse/cp310",
|
||||
"wheelhouse/cp311",
|
||||
"wheelhouse/cp312",
|
||||
],
|
||||
managed_paths={
|
||||
"install_root": "~/.local/share/aman",
|
||||
"current_link": "~/.local/share/aman/current",
|
||||
"shim": "~/.local/bin/aman",
|
||||
"service": "~/.config/systemd/user/aman.service",
|
||||
"state": "~/.local/share/aman/install-state.json",
|
||||
},
|
||||
smoke_check_code=DEFAULT_SMOKE_CHECK_CODE,
|
||||
runtime_dependency_hint=DEFAULT_RUNTIME_DEPENDENCY_HINT,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallState:
|
||||
app_name: str
|
||||
install_kind: str
|
||||
version: str
|
||||
installed_at: str
|
||||
service_mode: str
|
||||
architecture: str
|
||||
supported_python_tags: list[str]
|
||||
paths: dict[str, str]
|
||||
|
||||
|
||||
def _portable_tag() -> str:
|
||||
test_override = os.environ.get("AMAN_PORTABLE_TEST_PYTHON_TAG", "").strip()
|
||||
if test_override:
|
||||
return test_override
|
||||
return f"cp{sys.version_info.major}{sys.version_info.minor}"
|
||||
|
||||
|
||||
def _load_manifest(bundle_dir: Path) -> Manifest:
|
||||
manifest_path = bundle_dir / "manifest.json"
|
||||
try:
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise PortableInstallError(f"missing manifest: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PortableInstallError(f"invalid manifest JSON: {manifest_path}") from exc
|
||||
try:
|
||||
return Manifest(**payload)
|
||||
except TypeError as exc:
|
||||
raise PortableInstallError(f"invalid manifest shape: {manifest_path}") from exc
|
||||
|
||||
|
||||
def _load_state(state_path: Path) -> InstallState | None:
|
||||
if not state_path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PortableInstallError(f"invalid install state JSON: {state_path}") from exc
|
||||
try:
|
||||
return InstallState(**payload)
|
||||
except TypeError as exc:
|
||||
raise PortableInstallError(f"invalid install state shape: {state_path}") from exc
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str, *, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.tmp-",
|
||||
delete=False,
|
||||
) as handle:
|
||||
handle.write(content)
|
||||
tmp_path = Path(handle.name)
|
||||
os.chmod(tmp_path, mode)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def _atomic_symlink(target: Path, link_path: Path) -> None:
|
||||
link_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_link = link_path.parent / f".{link_path.name}.tmp-{os.getpid()}"
|
||||
try:
|
||||
if tmp_link.exists() or tmp_link.is_symlink():
|
||||
tmp_link.unlink()
|
||||
os.symlink(str(target), tmp_link)
|
||||
os.replace(tmp_link, link_path)
|
||||
finally:
|
||||
if tmp_link.exists() or tmp_link.is_symlink():
|
||||
tmp_link.unlink()
|
||||
|
||||
|
||||
def _read_text_if_exists(path: Path) -> str | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _current_target(current_link: Path) -> Path | None:
|
||||
if current_link.is_symlink():
|
||||
target = os.readlink(current_link)
|
||||
target_path = Path(target)
|
||||
if not target_path.is_absolute():
|
||||
target_path = current_link.parent / target_path
|
||||
return target_path
|
||||
if current_link.exists():
|
||||
return current_link
|
||||
return None
|
||||
|
||||
|
||||
def _is_managed_text(content: str | None) -> bool:
|
||||
return bool(content and MANAGED_MARKER in content)
|
||||
|
||||
|
||||
def _run(
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
capture_output: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
args,
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=capture_output,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
details = exc.stderr.strip() or exc.stdout.strip() or str(exc)
|
||||
raise PortableInstallError(details) from exc
|
||||
|
||||
|
||||
def _run_systemctl(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(["systemctl", "--user", *args], check=check, capture_output=True)
|
||||
|
||||
|
||||
def _supported_tag_or_raise(manifest: Manifest) -> str:
|
||||
if sys.implementation.name != "cpython":
|
||||
raise PortableInstallError("portable installer requires CPython 3.10, 3.11, or 3.12")
|
||||
tag = _portable_tag()
|
||||
if tag not in manifest.supported_python_tags:
|
||||
version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
raise PortableInstallError(
|
||||
f"unsupported python3 version {version}; supported versions are CPython 3.10, 3.11, and 3.12"
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
def _check_preflight(manifest: Manifest, paths: InstallPaths) -> InstallState | None:
|
||||
_supported_tag_or_raise(manifest)
|
||||
if shutil.which("systemctl") is None:
|
||||
raise PortableInstallError("systemctl is required for the supported user service lifecycle")
|
||||
try:
|
||||
import venv as _venv # noqa: F401
|
||||
except Exception as exc: # pragma: no cover - import failure is environment dependent
|
||||
raise PortableInstallError("python3 venv support is required for the portable installer") from exc
|
||||
|
||||
state = _load_state(paths.state_path)
|
||||
if state is not None:
|
||||
if state.app_name != APP_NAME or state.install_kind != INSTALL_KIND:
|
||||
raise PortableInstallError(f"unexpected install state in {paths.state_path}")
|
||||
|
||||
shim_text = _read_text_if_exists(paths.shim_path)
|
||||
if shim_text is not None and (state is None or not _is_managed_text(shim_text)):
|
||||
raise PortableInstallError(
|
||||
f"refusing to overwrite unmanaged shim at {paths.shim_path}; remove it first"
|
||||
)
|
||||
|
||||
service_text = _read_text_if_exists(paths.service_path)
|
||||
if service_text is not None and (state is None or not _is_managed_text(service_text)):
|
||||
raise PortableInstallError(
|
||||
f"refusing to overwrite unmanaged service file at {paths.service_path}; remove it first"
|
||||
)
|
||||
|
||||
detected_aman = shutil.which(APP_NAME)
|
||||
if detected_aman:
|
||||
expected_paths = {str(paths.shim_path)}
|
||||
current_target = _current_target(paths.current_link)
|
||||
if current_target is not None:
|
||||
expected_paths.add(str(current_target / "venv" / "bin" / APP_NAME))
|
||||
if detected_aman not in expected_paths:
|
||||
raise PortableInstallError(
|
||||
"detected another Aman install in PATH at "
|
||||
f"{detected_aman}; remove that install before using the portable bundle"
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def _require_bundle_file(path: Path, description: str) -> Path:
|
||||
if not path.exists():
|
||||
raise PortableInstallError(f"missing {description}: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _aman_wheel(common_wheelhouse: Path) -> Path:
|
||||
wheels = sorted(common_wheelhouse.glob(f"{APP_NAME}-*.whl"))
|
||||
if not wheels:
|
||||
raise PortableInstallError(f"no Aman wheel found in {common_wheelhouse}")
|
||||
return wheels[-1]
|
||||
|
||||
|
||||
def _render_wrapper(paths: InstallPaths) -> str:
|
||||
exec_path = paths.current_link / "venv" / "bin" / APP_NAME
|
||||
return textwrap.dedent(
|
||||
f"""\
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
{MANAGED_MARKER}
|
||||
exec "{exec_path}" "$@"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _render_service(template_text: str, paths: InstallPaths) -> str:
|
||||
exec_start = (
|
||||
f"{paths.current_link / 'venv' / 'bin' / APP_NAME} "
|
||||
f"run --config {paths.home / '.config' / APP_NAME / 'config.json'}"
|
||||
)
|
||||
return template_text.replace("__EXEC_START__", exec_start)
|
||||
|
||||
|
||||
def _write_state(paths: InstallPaths, manifest: Manifest, version_dir: Path) -> None:
|
||||
state = InstallState(
|
||||
app_name=APP_NAME,
|
||||
install_kind=INSTALL_KIND,
|
||||
version=manifest.version,
|
||||
installed_at=datetime.now(timezone.utc).isoformat(),
|
||||
service_mode="systemd-user",
|
||||
architecture=manifest.architecture,
|
||||
supported_python_tags=list(manifest.supported_python_tags),
|
||||
paths={
|
||||
**paths.as_serializable(),
|
||||
"version_dir": str(version_dir),
|
||||
},
|
||||
)
|
||||
_atomic_write(paths.state_path, json.dumps(asdict(state), indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _copy_bundle_support_files(bundle_dir: Path, stage_dir: Path) -> None:
|
||||
for name in ("manifest.json", "install.sh", "uninstall.sh", "portable_installer.py"):
|
||||
src = _require_bundle_file(bundle_dir / name, name)
|
||||
dst = stage_dir / name
|
||||
shutil.copy2(src, dst)
|
||||
if dst.suffix in {".sh", ".py"}:
|
||||
os.chmod(dst, 0o755)
|
||||
src_service_dir = _require_bundle_file(bundle_dir / "systemd", "systemd directory")
|
||||
dst_service_dir = stage_dir / "systemd"
|
||||
if dst_service_dir.exists():
|
||||
shutil.rmtree(dst_service_dir)
|
||||
shutil.copytree(src_service_dir, dst_service_dir)
|
||||
|
||||
|
||||
def _run_pip_install(bundle_dir: Path, stage_dir: Path, python_tag: str) -> None:
|
||||
common_dir = _require_bundle_file(bundle_dir / "wheelhouse" / "common", "common wheelhouse")
|
||||
version_dir = _require_bundle_file(bundle_dir / "wheelhouse" / python_tag, f"{python_tag} wheelhouse")
|
||||
aman_wheel = _aman_wheel(common_dir)
|
||||
venv_dir = stage_dir / "venv"
|
||||
_run([sys.executable, "-m", "venv", "--system-site-packages", str(venv_dir)])
|
||||
_run(
|
||||
[
|
||||
str(venv_dir / "bin" / "python"),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-index",
|
||||
"--find-links",
|
||||
str(common_dir),
|
||||
"--find-links",
|
||||
str(version_dir),
|
||||
str(aman_wheel),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _run_smoke_check(stage_dir: Path, manifest: Manifest) -> None:
|
||||
venv_python = stage_dir / "venv" / "bin" / "python"
|
||||
try:
|
||||
_run([str(venv_python), "-c", manifest.smoke_check_code], capture_output=True)
|
||||
except PortableInstallError as exc:
|
||||
raise PortableInstallError(
|
||||
f"runtime dependency smoke check failed: {exc}\n{manifest.runtime_dependency_hint}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
def _rollback_install(
|
||||
*,
|
||||
paths: InstallPaths,
|
||||
manifest: Manifest,
|
||||
old_state_text: str | None,
|
||||
old_service_text: str | None,
|
||||
old_shim_text: str | None,
|
||||
old_current_target: Path | None,
|
||||
new_version_dir: Path,
|
||||
backup_dir: Path | None,
|
||||
) -> None:
|
||||
_remove_path(new_version_dir)
|
||||
if backup_dir is not None and backup_dir.exists():
|
||||
os.replace(backup_dir, new_version_dir)
|
||||
if old_current_target is not None:
|
||||
_atomic_symlink(old_current_target, paths.current_link)
|
||||
else:
|
||||
_remove_path(paths.current_link)
|
||||
if old_shim_text is not None:
|
||||
_atomic_write(paths.shim_path, old_shim_text, mode=0o755)
|
||||
else:
|
||||
_remove_path(paths.shim_path)
|
||||
if old_service_text is not None:
|
||||
_atomic_write(paths.service_path, old_service_text)
|
||||
else:
|
||||
_remove_path(paths.service_path)
|
||||
if old_state_text is not None:
|
||||
_atomic_write(paths.state_path, old_state_text)
|
||||
else:
|
||||
_remove_path(paths.state_path)
|
||||
_run_systemctl(["daemon-reload"], check=False)
|
||||
if old_current_target is not None and old_service_text is not None:
|
||||
_run_systemctl(["enable", "--now", SERVICE_NAME], check=False)
|
||||
|
||||
|
||||
def _prune_versions(paths: InstallPaths, keep_version: str) -> None:
|
||||
for entry in paths.share_root.iterdir():
|
||||
if entry.name in {"current", "install-state.json"}:
|
||||
continue
|
||||
if entry.is_dir() and entry.name != keep_version:
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
|
||||
|
||||
def install_bundle(bundle_dir: Path) -> int:
|
||||
manifest = _load_manifest(bundle_dir)
|
||||
paths = InstallPaths.detect()
|
||||
previous_state = _check_preflight(manifest, paths)
|
||||
python_tag = _supported_tag_or_raise(manifest)
|
||||
|
||||
paths.share_root.mkdir(parents=True, exist_ok=True)
|
||||
stage_dir = paths.share_root / f".staging-{manifest.version}-{os.getpid()}"
|
||||
version_dir = paths.share_root / manifest.version
|
||||
backup_dir: Path | None = None
|
||||
old_state_text = _read_text_if_exists(paths.state_path)
|
||||
old_service_text = _read_text_if_exists(paths.service_path)
|
||||
old_shim_text = _read_text_if_exists(paths.shim_path)
|
||||
old_current_target = _current_target(paths.current_link)
|
||||
service_template_path = _require_bundle_file(
|
||||
bundle_dir / "systemd" / f"{SERVICE_NAME}.service.in",
|
||||
"service template",
|
||||
)
|
||||
service_template = service_template_path.read_text(encoding="utf-8")
|
||||
cutover_done = False
|
||||
|
||||
if previous_state is not None:
|
||||
_run_systemctl(["stop", SERVICE_NAME], check=False)
|
||||
|
||||
_remove_path(stage_dir)
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
_run_pip_install(bundle_dir, stage_dir, python_tag)
|
||||
_copy_bundle_support_files(bundle_dir, stage_dir)
|
||||
_run_smoke_check(stage_dir, manifest)
|
||||
|
||||
if version_dir.exists():
|
||||
backup_dir = paths.share_root / f".rollback-{manifest.version}-{int(time.time())}"
|
||||
_remove_path(backup_dir)
|
||||
os.replace(version_dir, backup_dir)
|
||||
os.replace(stage_dir, version_dir)
|
||||
_atomic_symlink(version_dir, paths.current_link)
|
||||
_atomic_write(paths.shim_path, _render_wrapper(paths), mode=0o755)
|
||||
_atomic_write(paths.service_path, _render_service(service_template, paths))
|
||||
_write_state(paths, manifest, version_dir)
|
||||
cutover_done = True
|
||||
|
||||
_run_systemctl(["daemon-reload"])
|
||||
_run_systemctl(["enable", "--now", SERVICE_NAME])
|
||||
except Exception:
|
||||
_remove_path(stage_dir)
|
||||
if cutover_done or backup_dir is not None:
|
||||
_rollback_install(
|
||||
paths=paths,
|
||||
manifest=manifest,
|
||||
old_state_text=old_state_text,
|
||||
old_service_text=old_service_text,
|
||||
old_shim_text=old_shim_text,
|
||||
old_current_target=old_current_target,
|
||||
new_version_dir=version_dir,
|
||||
backup_dir=backup_dir,
|
||||
)
|
||||
else:
|
||||
_remove_path(stage_dir)
|
||||
raise
|
||||
|
||||
if backup_dir is not None:
|
||||
_remove_path(backup_dir)
|
||||
_prune_versions(paths, manifest.version)
|
||||
print(f"installed {APP_NAME} {manifest.version} in {version_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def uninstall_bundle(bundle_dir: Path, *, purge: bool) -> int:
|
||||
_ = bundle_dir
|
||||
paths = InstallPaths.detect()
|
||||
state = _load_state(paths.state_path)
|
||||
if state is None:
|
||||
raise PortableInstallError(f"no portable install state found at {paths.state_path}")
|
||||
if state.app_name != APP_NAME or state.install_kind != INSTALL_KIND:
|
||||
raise PortableInstallError(f"unexpected install state in {paths.state_path}")
|
||||
|
||||
shim_text = _read_text_if_exists(paths.shim_path)
|
||||
if shim_text is not None and not _is_managed_text(shim_text):
|
||||
raise PortableInstallError(f"refusing to remove unmanaged shim at {paths.shim_path}")
|
||||
service_text = _read_text_if_exists(paths.service_path)
|
||||
if service_text is not None and not _is_managed_text(service_text):
|
||||
raise PortableInstallError(f"refusing to remove unmanaged service at {paths.service_path}")
|
||||
|
||||
_run_systemctl(["disable", "--now", SERVICE_NAME], check=False)
|
||||
_remove_path(paths.service_path)
|
||||
_run_systemctl(["daemon-reload"], check=False)
|
||||
_remove_path(paths.shim_path)
|
||||
_remove_path(paths.share_root)
|
||||
if purge:
|
||||
_remove_path(paths.config_dir)
|
||||
_remove_path(paths.cache_dir)
|
||||
print(f"uninstalled {APP_NAME} portable bundle")
|
||||
return 0
|
||||
|
||||
|
||||
def write_manifest(version: str, output_path: Path) -> int:
|
||||
manifest = Manifest.default(version)
|
||||
_atomic_write(output_path, json.dumps(asdict(manifest), indent=2, sort_keys=True) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Aman portable bundle helper")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
install_parser = subparsers.add_parser("install", help="Install or upgrade the portable bundle")
|
||||
install_parser.add_argument("--bundle-dir", default=str(Path.cwd()))
|
||||
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall the portable bundle")
|
||||
uninstall_parser.add_argument("--bundle-dir", default=str(Path.cwd()))
|
||||
uninstall_parser.add_argument("--purge", action="store_true", help="Remove config and cache too")
|
||||
|
||||
manifest_parser = subparsers.add_parser("write-manifest", help="Write the portable bundle manifest")
|
||||
manifest_parser.add_argument("--version", required=True)
|
||||
manifest_parser.add_argument("--output", required=True)
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv or sys.argv[1:])
|
||||
try:
|
||||
if args.command == "install":
|
||||
return install_bundle(Path(args.bundle_dir).resolve())
|
||||
if args.command == "uninstall":
|
||||
return uninstall_bundle(Path(args.bundle_dir).resolve(), purge=args.purge)
|
||||
if args.command == "write-manifest":
|
||||
return write_manifest(args.version, Path(args.output).resolve())
|
||||
except PortableInstallError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
13
packaging/portable/systemd/aman.service.in
Normal file
13
packaging/portable/systemd/aman.service.in
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# managed by aman portable installer
|
||||
[Unit]
|
||||
Description=aman X11 STT daemon
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=__EXEC_START__
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
5
packaging/portable/uninstall.sh
Executable file
5
packaging/portable/uninstall.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "${SCRIPT_DIR}/portable_installer.py" uninstall --bundle-dir "${SCRIPT_DIR}" "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue