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