diff --git a/Makefile b/Makefile index 223ef7b..ffc9b58 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BUILD_DIR := $(CURDIR)/build RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) RUN_CONFIG := $(if $(RUN_ARGS),$(abspath $(firstword $(RUN_ARGS))),$(CONFIG)) -.PHONY: run doctor self-check eval-models build-heuristic-dataset sync-default-model check-default-model sync test check build package package-deb package-arch release-check install-local install-service install clean-dist clean-build clean +.PHONY: run doctor self-check eval-models build-heuristic-dataset sync-default-model check-default-model sync test check build package package-deb package-arch package-portable release-check install-local install-service install clean-dist clean-build clean EVAL_DATASET ?= $(CURDIR)/benchmarks/cleanup_dataset.jsonl EVAL_MATRIX ?= $(CURDIR)/benchmarks/model_matrix.small_first.json EVAL_OUTPUT ?= $(CURDIR)/benchmarks/results/latest.json @@ -56,7 +56,7 @@ check: build: $(PYTHON) -m build --no-isolation -package: package-deb package-arch +package: package-deb package-arch package-portable package-deb: ./scripts/package_deb.sh @@ -64,6 +64,9 @@ package-deb: package-arch: ./scripts/package_arch.sh +package-portable: + ./scripts/package_portable.sh + release-check: $(MAKE) check-default-model $(PYTHON) -m py_compile src/*.py tests/*.py diff --git a/README.md b/README.md index 86e138d..2a559e6 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,22 @@ Python X11 STT daemon that records audio, runs Whisper, applies local AI cleanup The canonical Aman user is a desktop professional who wants dictation and rewriting features without learning Python tooling. -- End-user path today: distro-specific release artifacts. -- GA target: portable X11 release bundle for mainstream distros. +- End-user path: portable X11 release bundle for mainstream distros. +- Alternate package channels: Debian/Ubuntu `.deb` and Arch packaging inputs. - Developer path: Python/uv workflows. Persona details and distribution policy are documented in [`docs/persona-and-distribution.md`](docs/persona-and-distribution.md). -## Current Release Channels +## Release Channels -Aman is not GA yet for X11 users across distros. Today the maintained release +Aman is not GA yet for X11 users across distros. The maintained release channels are: -- Debian/Ubuntu `.deb`: current end-user channel. -- Arch `PKGBUILD` plus source tarball: current maintainer and power-user channel. +- Portable X11 bundle: current canonical end-user channel. +- Debian/Ubuntu `.deb`: secondary packaged channel. +- Arch `PKGBUILD` plus source tarball: secondary maintainer and power-user channel. - Python wheel and sdist: current developer and integrator channel. -- The portable X11 installer described in the GA roadmap is the target - distribution model, but it is not shipped yet. ## GA Support Matrix @@ -36,9 +35,42 @@ channels are: | Manual foreground mode | `aman run` for setup, support, and debugging | | Canonical recovery sequence | `aman doctor` -> `aman self-check` -> `journalctl --user -u aman` -> `aman run --verbose` | | Representative GA validation families | Debian/Ubuntu, Arch, Fedora, openSUSE | -| Portable installer prerequisite | System `python3` 3.10+ for the future GA installer | +| Portable installer prerequisite | System CPython `3.10`, `3.11`, or `3.12` | -## Current Install Instructions +## Install (Portable Bundle) + +Download `aman-x11-linux-.tar.gz` and +`aman-x11-linux-.tar.gz.sha256`, install the runtime dependencies for +your distro, then install the bundle: + +```bash +sha256sum -c aman-x11-linux-.tar.gz.sha256 +tar -xzf aman-x11-linux-.tar.gz +cd aman-x11-linux- +./install.sh +``` + +The installer writes the user service, updates `~/.local/bin/aman`, and runs +`systemctl --user enable --now aman` automatically. +On first service start, Aman opens the graphical settings window if +`~/.config/aman/config.json` does not exist yet. + +Upgrade by extracting the newer bundle and running its `install.sh` again. +Config and cache are preserved by default. + +Uninstall with: + +```bash +~/.local/share/aman/current/uninstall.sh +``` + +Add `--purge` if you also want to remove `~/.config/aman/` and +`~/.cache/aman/`. + +Detailed install, upgrade, uninstall, and conflict guidance lives in +[`docs/portable-install.md`](docs/portable-install.md). + +## Secondary Channels ### Debian/Ubuntu (`.deb`) @@ -46,11 +78,6 @@ Download a release artifact and install it: ```bash sudo apt install ./aman__.deb -``` - -Then enable the user service: - -```bash systemctl --user daemon-reload systemctl --user enable --now aman ``` @@ -66,8 +93,6 @@ or your own packaging pipeline. service. - Supported manual path: use `aman run` in the foreground while setting up, debugging, or collecting support logs. -- Current release channels still differ by distro. The portable installer is the - milestone 2 target, not part of the current release. ## Recovery Sequence @@ -121,17 +146,18 @@ sudo zypper install -y portaudio gtk3 libayatana-appindicator3-1 python3-gobject -## Quickstart (Current Release) +## Quickstart (Portable Bundle) -For supported daily use on current release channels: +For supported daily use on the portable bundle: 1. Install the runtime dependencies for your distro. -2. Install the current release artifact for your distro. -3. Enable and start the user service: +2. Download and extract the portable release bundle. +3. Run `./install.sh` from the extracted bundle. +4. Save the first-run settings window. +5. Validate the install: ```bash -systemctl --user daemon-reload -systemctl --user enable --now aman +aman self-check --config ~/.config/aman/config.json ``` If you need the manual foreground path for setup or support: @@ -285,7 +311,9 @@ make install-service Service notes: - The supported daily-use path is the user service. -- The user unit launches `aman` from `PATH`. +- The portable installer writes and enables the user unit automatically. +- The local developer unit launched by `make install-service` still resolves + `aman` from `PATH`. - Package installs should provide the `aman` command automatically. - Use `aman run --config ~/.config/aman/config.json` in the foreground for setup, support, or debugging. @@ -323,11 +351,15 @@ Build and packaging (maintainers): ```bash make build make package +make package-portable make package-deb make package-arch make release-check ``` +`make package-portable` builds `dist/aman-x11-linux-.tar.gz` plus its +`.sha256` file. + `make package-deb` installs Python dependencies while creating the package. For offline packaging, set `AMAN_WHEELHOUSE_DIR` to a directory containing the required wheels. diff --git a/docs/persona-and-distribution.md b/docs/persona-and-distribution.md index 2491c3c..5b2254e 100644 --- a/docs/persona-and-distribution.md +++ b/docs/persona-and-distribution.md @@ -36,18 +36,17 @@ Design implications: The current release channels are: -1. Current end-user channel: Debian package (`.deb`) for Ubuntu/Debian users. -2. Secondary: Arch package inputs (`PKGBUILD` + source tarball). -3. Developer: wheel and sdist from `python -m build`. - -The portable X11 installer is the GA target channel, not the current shipped -channel. +1. Current canonical end-user channel: portable X11 bundle (`aman-x11-linux-.tar.gz`). +2. Secondary packaged channel: Debian package (`.deb`) for Ubuntu/Debian users. +3. Secondary maintainer channel: Arch package inputs (`PKGBUILD` + source tarball). +4. Developer: wheel and sdist from `python -m build`. ## GA Target Support Contract For X11 GA, Aman supports: - X11 desktop sessions only. +- System CPython `3.10`, `3.11`, or `3.12` for the portable installer. - Runtime dependencies installed from the distro package manager. - `systemd --user` as the supported daily-use path. - `aman run` as the foreground setup, support, and debugging path. @@ -59,6 +58,14 @@ For X11 GA, Aman supports: not mean native-package parity or exhaustive certification for every Linux variant. +## Canonical end-user lifecycle + +- Install: extract the portable bundle and run `./install.sh`. +- Update: extract the newer portable bundle and run its `./install.sh`. +- Uninstall: run `~/.local/share/aman/current/uninstall.sh`. +- Purge uninstall: run `~/.local/share/aman/current/uninstall.sh --purge`. +- Recovery: `aman doctor` -> `aman self-check` -> `journalctl --user -u aman` -> `aman run --verbose`. + ## Out of Scope for X11 GA - Wayland production support. diff --git a/docs/portable-install.md b/docs/portable-install.md new file mode 100644 index 0000000..fea2667 --- /dev/null +++ b/docs/portable-install.md @@ -0,0 +1,146 @@ +# Portable X11 Install Guide + +This is the canonical end-user install path for Aman on X11. + +## Supported environment + +- X11 desktop session +- `systemd --user` +- System CPython `3.10`, `3.11`, or `3.12` +- Runtime dependencies installed from the distro package manager + +## Runtime dependencies + +Install the runtime dependencies for your distro before running `install.sh`. + +### Ubuntu/Debian + +```bash +sudo apt install -y libportaudio2 python3-gi python3-xlib gir1.2-gtk-3.0 libayatana-appindicator3-1 +``` + +### Arch Linux + +```bash +sudo pacman -S --needed portaudio gtk3 libayatana-appindicator python-gobject python-xlib +``` + +### Fedora + +```bash +sudo dnf install -y portaudio gtk3 libayatana-appindicator-gtk3 python3-gobject python3-xlib +``` + +### openSUSE + +```bash +sudo zypper install -y portaudio gtk3 libayatana-appindicator3-1 python3-gobject python3-python-xlib +``` + +## Fresh install + +1. Download `aman-x11-linux-.tar.gz` and `aman-x11-linux-.tar.gz.sha256`. +2. Verify the checksum. +3. Extract the bundle. +4. Run `install.sh`. + +```bash +sha256sum -c aman-x11-linux-.tar.gz.sha256 +tar -xzf aman-x11-linux-.tar.gz +cd aman-x11-linux- +./install.sh +``` + +The installer: + +- creates `~/.local/share/aman//` +- updates `~/.local/share/aman/current` +- creates `~/.local/bin/aman` +- installs `~/.config/systemd/user/aman.service` +- runs `systemctl --user daemon-reload` +- runs `systemctl --user enable --now aman` + +If `~/.config/aman/config.json` does not exist yet, the first service start +opens the graphical settings window automatically. + +After saving the first-run settings, validate the install with: + +```bash +aman self-check --config ~/.config/aman/config.json +``` + +## Upgrade + +Extract the new bundle and run the new `install.sh` again. + +```bash +tar -xzf aman-x11-linux-.tar.gz +cd aman-x11-linux- +./install.sh +``` + +Upgrade behavior: + +- existing config in `~/.config/aman/` is preserved +- existing cache in `~/.cache/aman/` is preserved +- the old installed version is removed after the new one passes install and service restart +- the service is restarted on the new version automatically + +## Uninstall + +Run the installed uninstaller from the active install: + +```bash +~/.local/share/aman/current/uninstall.sh +``` + +Default uninstall removes: + +- `~/.local/share/aman/` +- `~/.local/bin/aman` +- `~/.config/systemd/user/aman.service` + +Default uninstall preserves: + +- `~/.config/aman/` +- `~/.cache/aman/` + +## Purge uninstall + +To remove config and cache too: + +```bash +~/.local/share/aman/current/uninstall.sh --purge +``` + +## Filesystem layout + +- Installed payload: `~/.local/share/aman//` +- Active symlink: `~/.local/share/aman/current` +- Command shim: `~/.local/bin/aman` +- Install state: `~/.local/share/aman/install-state.json` +- User service: `~/.config/systemd/user/aman.service` + +## Conflict resolution + +The portable installer refuses to overwrite: + +- an unmanaged `~/.local/bin/aman` +- an unmanaged `~/.config/systemd/user/aman.service` +- another non-portable `aman` found earlier in `PATH` + +If you already installed Aman from a distro package: + +1. uninstall the distro package +2. remove any leftover `aman` command from `PATH` +3. remove any leftover user service file +4. rerun the portable `install.sh` + +## Recovery path + +If installation succeeds but runtime behavior is wrong, use the supported recovery order: + +1. `aman doctor --config ~/.config/aman/config.json` +2. `aman self-check --config ~/.config/aman/config.json` +3. `journalctl --user -u aman -f` +4. `aman run --config ~/.config/aman/config.json --verbose` diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 1763b7d..37dd98a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,7 +1,7 @@ # Release Checklist -This checklist covers both current releases and the future X11 GA bar. The GA -signoff sections are required for `v1.0.0` and later. +This checklist covers the current portable X11 release flow and the remaining +GA signoff bar. The GA signoff sections are required for `v1.0.0` and later. 1. Update `CHANGELOG.md` with final release notes. 2. Bump `project.version` in `pyproject.toml`. @@ -16,19 +16,25 @@ signoff sections are required for `v1.0.0` and later. - `make package` 6. Verify artifacts: - `dist/*.whl` - - `dist/*.tar.gz` + - `dist/aman-x11-linux-.tar.gz` + - `dist/aman-x11-linux-.tar.gz.sha256` - `dist/*.deb` - `dist/arch/PKGBUILD` 7. Tag release: - `git tag vX.Y.Z` - `git push origin vX.Y.Z` 8. Publish release and upload package artifacts from `dist/`. -9. GA support-contract signoff (`v1.0.0` and later): +9. Portable bundle release signoff: + - `README.md` points end users to the portable bundle first. + - [`docs/portable-install.md`](./portable-install.md) matches the shipped install, upgrade, uninstall, and purge behavior. + - `make package-portable` produces the portable tarball and checksum. + - `docs/x11-ga/portable-validation-matrix.md` contains current automated evidence and release-specific manual validation entries. +10. GA support-contract signoff (`v1.0.0` and later): - `README.md` and `docs/persona-and-distribution.md` agree on supported environment assumptions. - The support matrix names X11, runtime dependency ownership, `systemd --user`, and the representative distro families. - Service mode is documented as the default daily-use path and `aman run` as the manual support/debug path. - The recovery sequence `aman doctor` -> `aman self-check` -> `journalctl --user -u aman` -> `aman run --verbose` is documented consistently. -10. GA validation signoff (`v1.0.0` and later): +11. GA validation signoff (`v1.0.0` and later): - Validation evidence exists for Debian/Ubuntu, Arch, Fedora, and openSUSE. - The portable installer, upgrade path, and uninstall path are validated. - End-user docs and release notes match the shipped artifact set. diff --git a/docs/x11-ga/README.md b/docs/x11-ga/README.md index 663c88d..2782532 100644 --- a/docs/x11-ga/README.md +++ b/docs/x11-ga/README.md @@ -33,7 +33,7 @@ The GA support promise for Aman should be: - Linux desktop sessions running X11. - Mainstream distros with `systemd --user` available. -- System `python3` 3.10+ available for the portable installer. +- System CPython `3.10`, `3.11`, or `3.12` available for the portable installer. - Runtime dependencies installed from the distro package manager. - Service mode is the default end-user mode. - Foreground `aman run` remains a support and debugging path, not the primary daily-use path. @@ -87,7 +87,11 @@ Any future docs, tray copy, and release notes should point users to this same se the GA contract; `docs/release-checklist.md` now includes GA signoff gates; CLI help text now matches the same service/support language. - [ ] [Milestone 2: Portable Install, Update, and Uninstall](./02-portable-install-update-uninstall.md) - Build one reliable end-user lifecycle that works across mainstream X11 distros. + Implementation landed on 2026-03-12: the portable bundle, installer, + uninstaller, docs, and automated lifecycle tests are in the repo. Leave this + milestone open until the representative distro rows in + [`portable-validation-matrix.md`](./portable-validation-matrix.md) are filled + with real manual validation evidence. - [ ] [Milestone 3: Runtime Reliability and Diagnostics](./03-runtime-reliability-and-diagnostics.md) Make startup, failure handling, and recovery predictable. - [ ] [Milestone 4: First-Run UX and Support Docs](./04-first-run-ux-and-support-docs.md) diff --git a/docs/x11-ga/portable-validation-matrix.md b/docs/x11-ga/portable-validation-matrix.md new file mode 100644 index 0000000..526ff67 --- /dev/null +++ b/docs/x11-ga/portable-validation-matrix.md @@ -0,0 +1,43 @@ +# Portable Validation Matrix + +This document tracks milestone 2 and GA validation evidence for the portable +X11 bundle. + +## Automated evidence + +Completed on 2026-03-12: + +- `PYTHONPATH=src python3 -m unittest tests.test_portable_bundle` + - covers bundle packaging shape, fresh install, upgrade, uninstall, purge, + unmanaged-conflict fail-fast behavior, and rollback after service-start + failure +- `PYTHONPATH=src python3 -m unittest tests.test_aman_cli tests.test_diagnostics tests.test_portable_bundle` + - confirms portable bundle work did not regress the CLI help or diagnostics + surfaces used in the support flow + +## Manual distro validation + +These rows must be filled with real results before milestone 2 can be closed as +fully complete for GA evidence. + +| Distro family | Fresh install | First service start | Upgrade | Uninstall | Reinstall | Reboot or service restart | Missing dependency recovery | Conflict with prior package install | Reviewer | Status | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Debian/Ubuntu | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | | +| Arch | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | | +| Fedora | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | | +| openSUSE | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | Pending | | + +## Required release scenarios + +Every row above must cover: + +- runtime dependencies installed with the documented distro command +- bundle checksum verified +- `./install.sh` succeeds +- `systemctl --user enable --now aman` succeeds through the installer +- first launch reaches the normal settings or tray workflow +- upgrade preserves `~/.config/aman/` and `~/.cache/aman/` +- uninstall removes the command shim and user service cleanly +- reinstall succeeds after uninstall +- missing dependency path gives actionable remediation +- pre-existing distro package or unmanaged shim conflict fails clearly diff --git a/packaging/portable/install.sh b/packaging/portable/install.sh new file mode 100755 index 0000000..4e4e62a --- /dev/null +++ b/packaging/portable/install.sh @@ -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}" "$@" diff --git a/packaging/portable/portable_installer.py b/packaging/portable/portable_installer.py new file mode 100755 index 0000000..11910d7 --- /dev/null +++ b/packaging/portable/portable_installer.py @@ -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()) diff --git a/packaging/portable/systemd/aman.service.in b/packaging/portable/systemd/aman.service.in new file mode 100644 index 0000000..046029a --- /dev/null +++ b/packaging/portable/systemd/aman.service.in @@ -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 diff --git a/packaging/portable/uninstall.sh b/packaging/portable/uninstall.sh new file mode 100755 index 0000000..a54539b --- /dev/null +++ b/packaging/portable/uninstall.sh @@ -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}" "$@" diff --git a/scripts/package_common.sh b/scripts/package_common.sh index 57b1a59..63a7138 100755 --- a/scripts/package_common.sh +++ b/scripts/package_common.sh @@ -3,8 +3,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -DIST_DIR="${ROOT_DIR}/dist" -BUILD_DIR="${ROOT_DIR}/build" +DIST_DIR="${DIST_DIR:-${ROOT_DIR}/dist}" +BUILD_DIR="${BUILD_DIR:-${ROOT_DIR}/build}" APP_NAME="aman" mkdir -p "${DIST_DIR}" "${BUILD_DIR}" @@ -20,7 +20,7 @@ require_command() { project_version() { require_command python3 - python3 - <<'PY' +python3 - <<'PY' from pathlib import Path import re @@ -48,12 +48,13 @@ PY build_wheel() { require_command python3 - python3 -m build --wheel --no-isolation + python3 -m build --wheel --no-isolation --outdir "${DIST_DIR}" } latest_wheel_path() { require_command python3 python3 - <<'PY' +import os from pathlib import Path import re @@ -64,9 +65,10 @@ if not name_match or not version_match: raise SystemExit("project metadata not found in pyproject.toml") name = name_match.group(1).replace("-", "_") version = version_match.group(1) -candidates = sorted(Path("dist").glob(f"{name}-{version}-*.whl")) +dist_dir = Path(os.environ.get("DIST_DIR", "dist")) +candidates = sorted(dist_dir.glob(f"{name}-{version}-*.whl")) if not candidates: - raise SystemExit("no wheel artifact found in dist/") + raise SystemExit(f"no wheel artifact found in {dist_dir.resolve()}") print(candidates[-1]) PY } diff --git a/scripts/package_portable.sh b/scripts/package_portable.sh new file mode 100755 index 0000000..4120169 --- /dev/null +++ b/scripts/package_portable.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +source "${SCRIPT_DIR}/package_common.sh" + +require_command python3 +require_command tar +require_command sha256sum +require_command uv + +export UV_CACHE_DIR="${UV_CACHE_DIR:-${ROOT_DIR}/.uv-cache}" +export PIP_CACHE_DIR="${PIP_CACHE_DIR:-${ROOT_DIR}/.pip-cache}" +mkdir -p "${UV_CACHE_DIR}" "${PIP_CACHE_DIR}" + +VERSION="$(project_version)" +PACKAGE_NAME="$(project_name)" +BUNDLE_NAME="${PACKAGE_NAME}-x11-linux-${VERSION}" +PORTABLE_STAGE_DIR="${BUILD_DIR}/portable/${BUNDLE_NAME}" +PORTABLE_TARBALL="${DIST_DIR}/${BUNDLE_NAME}.tar.gz" +PORTABLE_CHECKSUM="${PORTABLE_TARBALL}.sha256" +TEST_WHEELHOUSE_ROOT="${AMAN_PORTABLE_TEST_WHEELHOUSE_ROOT:-}" + +copy_prebuilt_wheelhouse() { + local source_root="$1" + local target_root="$2" + local tag + for tag in cp310 cp311 cp312; do + local source_dir="${source_root}/${tag}" + if [[ ! -d "${source_dir}" ]]; then + echo "missing test wheelhouse directory: ${source_dir}" >&2 + exit 1 + fi + mkdir -p "${target_root}/${tag}" + cp -a "${source_dir}/." "${target_root}/${tag}/" + done +} + +export_requirements() { + local python_version="$1" + local output_path="$2" + local raw_path="${output_path}.raw" + uv export \ + --package "${PACKAGE_NAME}" \ + --no-dev \ + --no-editable \ + --format requirements-txt \ + --python "${python_version}" >"${raw_path}" + python3 - "${raw_path}" "${output_path}" <<'PY' +from pathlib import Path +import sys + +raw_path = Path(sys.argv[1]) +output_path = Path(sys.argv[2]) +lines = raw_path.read_text(encoding="utf-8").splitlines() +filtered = [line for line in lines if line.strip() != "."] +output_path.write_text("\n".join(filtered) + "\n", encoding="utf-8") +raw_path.unlink() +PY +} + +download_python_wheels() { + local python_tag="$1" + local python_version="$2" + local abi="$3" + local requirements_path="$4" + local target_dir="$5" + mkdir -p "${target_dir}" + python3 -m pip download \ + --requirement "${requirements_path}" \ + --dest "${target_dir}" \ + --only-binary=:all: \ + --implementation cp \ + --python-version "${python_version}" \ + --abi "${abi}" +} + +build_wheel +WHEEL_PATH="$(latest_wheel_path)" + +rm -rf "${PORTABLE_STAGE_DIR}" +mkdir -p "${PORTABLE_STAGE_DIR}/wheelhouse/common" +mkdir -p "${PORTABLE_STAGE_DIR}/systemd" + +cp "${WHEEL_PATH}" "${PORTABLE_STAGE_DIR}/wheelhouse/common/" +cp "${ROOT_DIR}/packaging/portable/install.sh" "${PORTABLE_STAGE_DIR}/install.sh" +cp "${ROOT_DIR}/packaging/portable/uninstall.sh" "${PORTABLE_STAGE_DIR}/uninstall.sh" +cp "${ROOT_DIR}/packaging/portable/portable_installer.py" "${PORTABLE_STAGE_DIR}/portable_installer.py" +cp "${ROOT_DIR}/packaging/portable/systemd/aman.service.in" "${PORTABLE_STAGE_DIR}/systemd/aman.service.in" +chmod 0755 \ + "${PORTABLE_STAGE_DIR}/install.sh" \ + "${PORTABLE_STAGE_DIR}/uninstall.sh" \ + "${PORTABLE_STAGE_DIR}/portable_installer.py" + +python3 "${ROOT_DIR}/packaging/portable/portable_installer.py" \ + write-manifest \ + --version "${VERSION}" \ + --output "${PORTABLE_STAGE_DIR}/manifest.json" + +if [[ -n "${TEST_WHEELHOUSE_ROOT}" ]]; then + copy_prebuilt_wheelhouse "${TEST_WHEELHOUSE_ROOT}" "${PORTABLE_STAGE_DIR}/wheelhouse" +else + TMP_REQ_DIR="${BUILD_DIR}/portable/requirements" + mkdir -p "${TMP_REQ_DIR}" + export_requirements "3.10" "${TMP_REQ_DIR}/cp310.txt" + export_requirements "3.11" "${TMP_REQ_DIR}/cp311.txt" + export_requirements "3.12" "${TMP_REQ_DIR}/cp312.txt" + download_python_wheels "cp310" "310" "cp310" "${TMP_REQ_DIR}/cp310.txt" "${PORTABLE_STAGE_DIR}/wheelhouse/cp310" + download_python_wheels "cp311" "311" "cp311" "${TMP_REQ_DIR}/cp311.txt" "${PORTABLE_STAGE_DIR}/wheelhouse/cp311" + download_python_wheels "cp312" "312" "cp312" "${TMP_REQ_DIR}/cp312.txt" "${PORTABLE_STAGE_DIR}/wheelhouse/cp312" +fi + +rm -f "${PORTABLE_TARBALL}" "${PORTABLE_CHECKSUM}" +tar -C "${BUILD_DIR}/portable" -czf "${PORTABLE_TARBALL}" "${BUNDLE_NAME}" +( + cd "${DIST_DIR}" + sha256sum "$(basename "${PORTABLE_TARBALL}")" >"$(basename "${PORTABLE_CHECKSUM}")" +) + +echo "built ${PORTABLE_TARBALL}" diff --git a/src/constants.py b/src/constants.py index 7ec23b8..95387c8 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path @@ -5,10 +6,13 @@ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "aman" / "config.json" RECORD_TIMEOUT_SEC = 300 TRAY_UPDATE_MS = 250 _MODULE_ASSETS_DIR = Path(__file__).parent / "assets" +_PREFIX_SHARE_ASSETS_DIR = Path(sys.prefix) / "share" / "aman" / "assets" _LOCAL_SHARE_ASSETS_DIR = Path.home() / ".local" / "share" / "aman" / "src" / "assets" _SYSTEM_SHARE_ASSETS_DIR = Path("/usr/local/share/aman/assets") if _MODULE_ASSETS_DIR.exists(): ASSETS_DIR = _MODULE_ASSETS_DIR +elif _PREFIX_SHARE_ASSETS_DIR.exists(): + ASSETS_DIR = _PREFIX_SHARE_ASSETS_DIR elif _LOCAL_SHARE_ASSETS_DIR.exists(): ASSETS_DIR = _LOCAL_SHARE_ASSETS_DIR else: diff --git a/tests/test_portable_bundle.py b/tests/test_portable_bundle.py new file mode 100644 index 0000000..67d2c47 --- /dev/null +++ b/tests/test_portable_bundle.py @@ -0,0 +1,358 @@ +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PORTABLE_DIR = ROOT / "packaging" / "portable" +if str(PORTABLE_DIR) not in sys.path: + sys.path.insert(0, str(PORTABLE_DIR)) + +import portable_installer as portable + + +def _project_version() -> str: + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$', text) + if not match: + raise RuntimeError("project version not found") + return match.group(1) + + +def _write_file(path: Path, content: str, *, mode: int | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if mode is not None: + path.chmod(mode) + + +def _build_fake_wheel(root: Path, version: str) -> Path: + root.mkdir(parents=True, exist_ok=True) + wheel_path = root / f"aman-{version}-py3-none-any.whl" + dist_info = f"aman-{version}.dist-info" + module_code = f'VERSION = "{version}"\n\ndef main():\n print(VERSION)\n return 0\n' + with zipfile.ZipFile(wheel_path, "w") as archive: + archive.writestr("portable_test_app.py", module_code) + archive.writestr( + f"{dist_info}/METADATA", + "\n".join( + [ + "Metadata-Version: 2.1", + "Name: aman", + f"Version: {version}", + "Summary: portable bundle test wheel", + "", + ] + ), + ) + archive.writestr( + f"{dist_info}/WHEEL", + "\n".join( + [ + "Wheel-Version: 1.0", + "Generator: test_portable_bundle", + "Root-Is-Purelib: true", + "Tag: py3-none-any", + "", + ] + ), + ) + archive.writestr( + f"{dist_info}/entry_points.txt", + "[console_scripts]\naman=portable_test_app:main\n", + ) + archive.writestr(f"{dist_info}/RECORD", "") + return wheel_path + + +def _bundle_dir(root: Path, version: str) -> Path: + bundle_dir = root / f"bundle-{version}" + (bundle_dir / "wheelhouse" / "common").mkdir(parents=True, exist_ok=True) + for tag in portable.SUPPORTED_PYTHON_TAGS: + (bundle_dir / "wheelhouse" / tag).mkdir(parents=True, exist_ok=True) + (bundle_dir / "systemd").mkdir(parents=True, exist_ok=True) + shutil.copy2(PORTABLE_DIR / "install.sh", bundle_dir / "install.sh") + shutil.copy2(PORTABLE_DIR / "uninstall.sh", bundle_dir / "uninstall.sh") + shutil.copy2(PORTABLE_DIR / "portable_installer.py", bundle_dir / "portable_installer.py") + shutil.copy2(PORTABLE_DIR / "systemd" / "aman.service.in", bundle_dir / "systemd" / "aman.service.in") + portable.write_manifest(version, bundle_dir / "manifest.json") + payload = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8")) + payload["smoke_check_code"] = "import portable_test_app" + (bundle_dir / "manifest.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + shutil.copy2(_build_fake_wheel(root / "wheelhouse", version), bundle_dir / "wheelhouse" / "common") + for name in ("install.sh", "uninstall.sh", "portable_installer.py"): + (bundle_dir / name).chmod(0o755) + return bundle_dir + + +def _systemctl_env(home: Path, *, extra_path: list[Path] | None = None, fail_match: str | None = None) -> tuple[dict[str, str], Path]: + fake_bin = home / "test-bin" + fake_bin.mkdir(parents=True, exist_ok=True) + log_path = home / "systemctl.log" + script_path = fake_bin / "systemctl" + _write_file( + script_path, + "\n".join( + [ + "#!/usr/bin/env python3", + "import os", + "import sys", + "from pathlib import Path", + "log_path = Path(os.environ['SYSTEMCTL_LOG'])", + "log_path.parent.mkdir(parents=True, exist_ok=True)", + "command = ' '.join(sys.argv[1:])", + "with log_path.open('a', encoding='utf-8') as handle:", + " handle.write(command + '\\n')", + "fail_match = os.environ.get('SYSTEMCTL_FAIL_MATCH', '')", + "if fail_match and fail_match in command:", + " print(f'forced failure: {command}', file=sys.stderr)", + " raise SystemExit(1)", + "raise SystemExit(0)", + "", + ] + ), + mode=0o755, + ) + search_path = [ + str(home / ".local" / "bin"), + *(str(path) for path in (extra_path or [])), + str(fake_bin), + os.environ["PATH"], + ] + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = os.pathsep.join(search_path) + env["SYSTEMCTL_LOG"] = str(log_path) + env["AMAN_PORTABLE_TEST_PYTHON_TAG"] = "cp311" + if fail_match: + env["SYSTEMCTL_FAIL_MATCH"] = fail_match + else: + env.pop("SYSTEMCTL_FAIL_MATCH", None) + return env, log_path + + +def _run_script(bundle_dir: Path, script_name: str, env: dict[str, str], *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(bundle_dir / script_name), *args], + cwd=bundle_dir, + env=env, + text=True, + capture_output=True, + check=check, + ) + + +def _manifest_with_supported_tags(bundle_dir: Path, tags: list[str]) -> None: + manifest_path = bundle_dir / "manifest.json" + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + payload["supported_python_tags"] = tags + manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _installed_version(home: Path) -> str: + installed_python = home / ".local" / "share" / "aman" / "current" / "venv" / "bin" / "python" + result = subprocess.run( + [str(installed_python), "-c", "import portable_test_app; print(portable_test_app.VERSION)"], + text=True, + capture_output=True, + check=True, + ) + return result.stdout.strip() + + +class PortableBundleTests(unittest.TestCase): + def test_package_portable_builds_bundle_and_checksum(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + dist_dir = tmp_path / "dist" + build_dir = tmp_path / "build" + test_wheelhouse = tmp_path / "wheelhouse" + for tag in portable.SUPPORTED_PYTHON_TAGS: + target_dir = test_wheelhouse / tag + target_dir.mkdir(parents=True, exist_ok=True) + _write_file(target_dir / f"{tag}-placeholder.whl", "placeholder\n") + env = os.environ.copy() + env["DIST_DIR"] = str(dist_dir) + env["BUILD_DIR"] = str(build_dir) + env["AMAN_PORTABLE_TEST_WHEELHOUSE_ROOT"] = str(test_wheelhouse) + env["UV_CACHE_DIR"] = str(tmp_path / ".uv-cache") + env["PIP_CACHE_DIR"] = str(tmp_path / ".pip-cache") + + subprocess.run( + ["bash", "./scripts/package_portable.sh"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + + version = _project_version() + tarball = dist_dir / f"aman-x11-linux-{version}.tar.gz" + checksum = dist_dir / f"aman-x11-linux-{version}.tar.gz.sha256" + self.assertTrue(tarball.exists()) + self.assertTrue(checksum.exists()) + with tarfile.open(tarball, "r:gz") as archive: + names = set(archive.getnames()) + prefix = f"aman-x11-linux-{version}" + self.assertIn(f"{prefix}/install.sh", names) + self.assertIn(f"{prefix}/uninstall.sh", names) + self.assertIn(f"{prefix}/portable_installer.py", names) + self.assertIn(f"{prefix}/manifest.json", names) + self.assertIn(f"{prefix}/wheelhouse/common", names) + self.assertIn(f"{prefix}/wheelhouse/cp310", names) + self.assertIn(f"{prefix}/wheelhouse/cp311", names) + self.assertIn(f"{prefix}/wheelhouse/cp312", names) + self.assertIn(f"{prefix}/systemd/aman.service.in", names) + + def test_fresh_install_creates_managed_paths_and_starts_service(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_dir = _bundle_dir(tmp_path, "0.1.0") + env, log_path = _systemctl_env(home) + + result = _run_script(bundle_dir, "install.sh", env) + + self.assertIn("installed aman 0.1.0", result.stdout) + current_link = home / ".local" / "share" / "aman" / "current" + self.assertTrue(current_link.is_symlink()) + self.assertEqual(current_link.resolve().name, "0.1.0") + self.assertEqual(_installed_version(home), "0.1.0") + shim_path = home / ".local" / "bin" / "aman" + service_path = home / ".config" / "systemd" / "user" / "aman.service" + state_path = home / ".local" / "share" / "aman" / "install-state.json" + self.assertIn(portable.MANAGED_MARKER, shim_path.read_text(encoding="utf-8")) + service_text = service_path.read_text(encoding="utf-8") + self.assertIn(portable.MANAGED_MARKER, service_text) + self.assertIn(str(current_link / "venv" / "bin" / "aman"), service_text) + payload = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(payload["version"], "0.1.0") + commands = log_path.read_text(encoding="utf-8") + self.assertIn("--user daemon-reload", commands) + self.assertIn("--user enable --now aman", commands) + + def test_upgrade_preserves_config_and_cache_and_prunes_old_version(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + env, _log_path = _systemctl_env(home) + bundle_v1 = _bundle_dir(tmp_path / "v1", "0.1.0") + bundle_v2 = _bundle_dir(tmp_path / "v2", "0.2.0") + + _run_script(bundle_v1, "install.sh", env) + config_path = home / ".config" / "aman" / "config.json" + cache_path = home / ".cache" / "aman" / "models" / "cached.bin" + _write_file(config_path, '{"config_version": 1}\n') + _write_file(cache_path, "cache\n") + + _run_script(bundle_v2, "install.sh", env) + + current_link = home / ".local" / "share" / "aman" / "current" + self.assertEqual(current_link.resolve().name, "0.2.0") + self.assertEqual(_installed_version(home), "0.2.0") + self.assertFalse((home / ".local" / "share" / "aman" / "0.1.0").exists()) + self.assertTrue(config_path.exists()) + self.assertTrue(cache_path.exists()) + + def test_unmanaged_shim_conflict_fails_before_mutation(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_dir = _bundle_dir(tmp_path, "0.1.0") + env, _log_path = _systemctl_env(home) + _write_file(home / ".local" / "bin" / "aman", "#!/usr/bin/env bash\necho nope\n", mode=0o755) + + result = _run_script(bundle_dir, "install.sh", env, check=False) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("unmanaged shim", result.stderr) + self.assertFalse((home / ".local" / "share" / "aman" / "install-state.json").exists()) + + def test_manifest_supported_tag_mismatch_fails_before_mutation(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_dir = _bundle_dir(tmp_path, "0.1.0") + _manifest_with_supported_tags(bundle_dir, ["cp399"]) + env, _log_path = _systemctl_env(home) + + result = _run_script(bundle_dir, "install.sh", env, check=False) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported python3 version", result.stderr) + self.assertFalse((home / ".local" / "share" / "aman").exists()) + + def test_uninstall_preserves_config_and_cache_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_dir = _bundle_dir(tmp_path, "0.1.0") + env, log_path = _systemctl_env(home) + + _run_script(bundle_dir, "install.sh", env) + _write_file(home / ".config" / "aman" / "config.json", '{"config_version": 1}\n') + _write_file(home / ".cache" / "aman" / "models" / "cached.bin", "cache\n") + + result = _run_script(bundle_dir, "uninstall.sh", env) + + self.assertIn("uninstalled aman portable bundle", result.stdout) + self.assertFalse((home / ".local" / "share" / "aman").exists()) + self.assertFalse((home / ".local" / "bin" / "aman").exists()) + self.assertFalse((home / ".config" / "systemd" / "user" / "aman.service").exists()) + self.assertTrue((home / ".config" / "aman" / "config.json").exists()) + self.assertTrue((home / ".cache" / "aman" / "models" / "cached.bin").exists()) + commands = log_path.read_text(encoding="utf-8") + self.assertIn("--user disable --now aman", commands) + + def test_uninstall_purge_removes_config_and_cache(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_dir = _bundle_dir(tmp_path, "0.1.0") + env, _log_path = _systemctl_env(home) + + _run_script(bundle_dir, "install.sh", env) + _write_file(home / ".config" / "aman" / "config.json", '{"config_version": 1}\n') + _write_file(home / ".cache" / "aman" / "models" / "cached.bin", "cache\n") + + _run_script(bundle_dir, "uninstall.sh", env, "--purge") + + self.assertFalse((home / ".config" / "aman").exists()) + self.assertFalse((home / ".cache" / "aman").exists()) + + def test_upgrade_rolls_back_when_service_restart_fails(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + home = tmp_path / "home" + bundle_v1 = _bundle_dir(tmp_path / "v1", "0.1.0") + bundle_v2 = _bundle_dir(tmp_path / "v2", "0.2.0") + good_env, _ = _systemctl_env(home) + failing_env, _ = _systemctl_env(home, fail_match="enable --now aman") + + _run_script(bundle_v1, "install.sh", good_env) + result = _run_script(bundle_v2, "install.sh", failing_env, check=False) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("forced failure", result.stderr) + self.assertEqual((home / ".local" / "share" / "aman" / "current").resolve().name, "0.1.0") + self.assertEqual(_installed_version(home), "0.1.0") + self.assertFalse((home / ".local" / "share" / "aman" / "0.2.0").exists()) + payload = json.loads( + (home / ".local" / "share" / "aman" / "install-state.json").read_text(encoding="utf-8") + ) + self.assertEqual(payload["version"], "0.1.0") + + +if __name__ == "__main__": + unittest.main()