Ship the portable X11 bundle lifecycle
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:
Thales Maciel 2026-03-12 15:01:26 -03:00
parent 511fab683a
commit a3368056ff
No known key found for this signature in database
GPG key ID: 33112E6833C34679
15 changed files with 1372 additions and 45 deletions

View file

@ -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

View file

@ -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-<version>.tar.gz` and
`aman-x11-linux-<version>.tar.gz.sha256`, install the runtime dependencies for
your distro, then install the bundle:
```bash
sha256sum -c aman-x11-linux-<version>.tar.gz.sha256
tar -xzf aman-x11-linux-<version>.tar.gz
cd aman-x11-linux-<version>
./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_<version>_<arch>.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
</details>
## 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-<version>.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.

View file

@ -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-<version>.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.

146
docs/portable-install.md Normal file
View file

@ -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-<version>.tar.gz` and `aman-x11-linux-<version>.tar.gz.sha256`.
2. Verify the checksum.
3. Extract the bundle.
4. Run `install.sh`.
```bash
sha256sum -c aman-x11-linux-<version>.tar.gz.sha256
tar -xzf aman-x11-linux-<version>.tar.gz
cd aman-x11-linux-<version>
./install.sh
```
The installer:
- creates `~/.local/share/aman/<version>/`
- 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-<new-version>.tar.gz
cd aman-x11-linux-<new-version>
./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/<version>/`
- 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`

View file

@ -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-<version>.tar.gz`
- `dist/aman-x11-linux-<version>.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.

View file

@ -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)

View file

@ -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

5
packaging/portable/install.sh Executable file
View 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}" "$@"

View 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())

View 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

View 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}" "$@"

View file

@ -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
}

121
scripts/package_portable.sh Executable file
View file

@ -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}"

View file

@ -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:

View file

@ -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()