Metadata-Version: 2.4
Name: uniservice
Version: 2.1.0
Summary: Cross-platform service manager built on systemd, launchd and Scheduled Tasks
Author-email: Kevin Huang <kevinwong.yiming@outlook.com>
Project-URL: Homepage, https://github.com/kevinhuang001/uniservice
Project-URL: Issues, https://github.com/kevinhuang001/uniservice/issues
Keywords: systemd,launchd,scheduled-tasks,service-manager,daemon
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Provides-Extra: build
Requires-Dist: pyinstaller>=6.0; extra == "build"

# uniservice

[中文说明](README.zh.md) · [![CI](https://github.com/kevinhuang001/uniservice/actions/workflows/ci.yml/badge.svg)](https://github.com/kevinhuang001/uniservice/actions/workflows/ci.yml)

Cross-platform service manager that delegates long-running processes, autostart and supervision to native OS mechanisms:

- Linux: systemd (user/system)
- macOS: launchd (LaunchAgents/LaunchDaemons)
- Windows: Scheduled Tasks (admin required)

More platform details: [details.md](details.md) ([中文](details.zh.md))

Requires **Python 3.10+**. No third-party runtime dependencies.

## Install

Every release publishes **two artifacts**, and the installer lets you pick one:

| | Artifact | Size | Requires |
| --- | --- | --- | --- |
| **default** | portable zipapp | ~30 KB | Python 3.10+ on the machine |
| `--binary` | standalone binary | ~24 MB | nothing (bundles its own CPython) |
| — | Python package ([PyPI](https://pypi.org/project/uniservice/)) | ~37 KB | pipx, uv or pip + Python 3.10+ |

**The zipapp is the recommended default**: one small file that is byte-identical on every
platform, and it runs on the Python you already have. The standalone binary exists for machines
with no Python environment; because PyInstaller cannot cross-compile, it is built and published
separately for each OS and CPU architecture.

Either way the command lands in `/usr/local/bin/uniservice` and is recorded in
`/usr/local/lib/uniservice/manifest`. `/usr/local/bin` is already on `PATH` for every account, so
the installer never edits a shell profile, and one installation serves both scopes:
`uniservice ...` for per-user services and `sudo uniservice ...` for system services.

Writing to `/usr/local` needs root, and the installer does not silently fall back somewhere else:
**run it with `sudo` or it stops with an error**. No `~/.local` install, no PATH edits.

### One-liner

```bash
# macOS / Linux — the recommended portable zipapp (needs Python 3.10+)
curl -fsSL https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install.sh | sudo bash

# ... or the standalone binary, which needs no Python at all
curl -fsSL https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install.sh | sudo bash -s -- --binary
```

```powershell
# Windows — the recommended portable zipapp (needs Python 3.10+)
iwr -useb https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install-windows.ps1 | iex

# ... or the standalone .exe, which needs no Python at all
$env:UNISERVICE_BINARY = 1; iwr -useb https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install-windows.ps1 | iex
```

Piping into `iex` leaves no file on disk, so the artifact is selected with the
`UNISERVICE_BINARY` environment variable. If you would rather use the switch, download the script
first:

```powershell
iwr -useb https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install-windows.ps1 -OutFile install-windows.ps1
./install-windows.ps1 -Binary
```

The same `UNISERVICE_BINARY=1` environment variable works for `install.sh`.

### PyPI

`uniservice` is published on [PyPI](https://pypi.org/project/uniservice/) as well. Install it
**globally**, because that is what puts the command where `sudo` can see it:

```bash
sudo pipx install --global uniservice    # venv in /opt/pipx, command in /usr/local/bin
uniservice --help                        # ... and `sudo uniservice ...` works too
```

`pipx --global` writes to `/opt/pipx` and `/usr/local/bin`, so it lands exactly where the installer
does. It does need pipx itself to be installed system-wide (apt, brew, or the standalone installer);
pipx installed with `pip install --user` cannot be run under `sudo`.

A per-user install still works, but the command then lives in your home directory, where `sudo`
never looks:

| How you install it | Command lands in | `sudo uniservice` |
| --- | --- | --- |
| `sudo pipx install --global uniservice` **(recommended)** | `/usr/local/bin` (venv in `/opt/pipx`) | works |
| `./install.sh` (above) | `/usr/local/bin` | works |
| `pipx install uniservice` | `~/.local/bin` | **not found** |
| `uv tool install uniservice` | `~/.local/bin` | **not found** |

`sudo` rebuilds `PATH` from `secure_path`, which never contains your home directory, so a per-user
install is simply invisible to it. `sudo "$(command -v uniservice)" ...` works around that, at the
cost of making root depend on a venv inside your home directory. `uv tool install` has no global
mode of its own — for a system-wide uv install you would have to redirect `UV_TOOL_DIR` yourself,
which is why pipx and the installer are the two supported ways to get `/usr/local/bin/uniservice`.

Uninstall with the frontend that installed it — `pipx uninstall uniservice` or
`uv tool uninstall uniservice`. The install scripts only ever remove a copy *they* wrote, so a PyPI
install is never touched by `install.sh --uninstall`.

### Installer options

```
--binary               install the standalone binary instead of the zipapp
--prefix DIR           install under DIR instead of /usr/local
                       (for packaging and tests; needs no elevated privileges)
--version TAG          install a specific release, e.g. --version v1.2.0
--sha256 HEX           verify the artifact against this digest
                       (by default the digest published in SHA256SUMS is used)
--from FILE            install a local zipapp or binary instead of downloading
--uninstall            remove a previous installation, using its manifest
```

```bash
sudo ./install.sh                                  # recommended: the zipapp
sudo ./install.sh --binary                         # no Python needed
sudo ./install.sh --version v1.2.0                 # pin a release
./install.sh --prefix /tmp/uniservice-test         # unprivileged, for packaging/tests
sudo ./install.sh --uninstall
```

The zipapp build is byte-for-byte reproducible, so pinning a `--sha256` gives a verifiable
install. When a release has no asset for your platform the installer says so and points at the
zipapp alternative.

### Release assets

| Asset | Notes |
| --- | --- |
| `uniservice` | the portable zipapp (recommended) |
| `uniservice-linux-x86_64`, `uniservice-linux-aarch64` | standalone binaries |
| `uniservice-macos-arm64`, `uniservice-macos-x86_64` | standalone binaries |
| `uniservice-windows-x86_64.exe` | standalone binary |
| `uniservice-<version>-py3-none-any.whl`, `.tar.gz` | the same code as a Python package, also on PyPI |
| `SHA256SUMS` | digests for everything above |

### Verify the checksum yourself

```bash
base=https://github.com/kevinhuang001/uniservice/releases/latest/download
curl -fsSLO "$base/uniservice" && curl -fsSLO "$base/SHA256SUMS"
grep ' uniservice$' SHA256SUMS | sha256sum -c -
sudo install -m 0755 uniservice /usr/local/bin/uniservice
```

### Windows notes

The default (zipapp) layout installs `uniservice.pyz` plus a `uniservice.cmd` shim into
`%LOCALAPPDATA%\uniservice\bin` and adds it to your user `PATH` and PowerShell profile.
`-Binary` installs `uniservice.exe` there instead, with no shim and no Python requirement.
`-Uninstall` reverses either.

Reopen the terminal, then:

```bash
uniservice --help
```

## Usage

```
uniservice <command> [options]
```

| Command | What it does |
| --- | --- |
| `list`, `ls` | list the services uniservice manages |
| `add NAME -- COMMAND...` | create, enable and start a service |
| `start`, `stop`, `restart NAME...` | change the running state |
| `enable`, `disable NAME...` | change whether it starts at boot |
| `remove`, `rm NAME...` | stop, disable and delete a service |
| `status [NAME]` | the native status of `NAME`, or a table of everything |
| `logs NAME [-f]` | print the captured output |
| `show NAME` | how the service is defined, and how to recreate it |
| `doctor` | check this machine end to end |
| `version` | print component versions |

Every control verb takes **one or more** names and reports each one, so
`uniservice restart api worker` is a single command; it exits non-zero if any of
them failed.

Every one of those acts on a service. **Installing and removing uniservice itself is the install
scripts' job**, not the command's: `install.sh --uninstall` and
`install-windows.ps1 -Uninstall` own the layout they wrote, and because they run as a different
process from the command they delete, there is no "cannot delete a running image" case to work
around on Windows. `uniservice version` and `uniservice doctor` still *report* where this copy lives
and how it was installed, which is what you need for a bug report.

### Scope (macOS/Linux)

The scope is derived from your privileges, not from a flag:

| How you run it | Scope | Definitions live in |
| --- | --- | --- |
| `uniservice ...` | user | `~/.config/systemd/user/`, `~/Library/LaunchAgents/` |
| `sudo uniservice ...` | system | `/etc/systemd/system/`, `/Library/LaunchDaemons/` |

Because the installer puts the command in `/usr/local/bin` (on every account's `PATH`), both
forms work with no further setup.

Two things change under `sudo`:

- the command after `--` is resolved with **root's** `PATH`, so `python3` may
  resolve to `/usr/bin/python3` instead of your conda/venv copy — pass an
  absolute path when that matters;
- the definition and its logs belong to root (`/root/.uniservice/logs/`).

On Windows there is no `sudo`: `uniservice list` works in any shell, every other
command needs an **Administrator** PowerShell/CMD.

### Add

```bash
uniservice add demo --workdir /tmp -- python3 -m http.server 8000
```

- If the executable is not an absolute path, uniservice resolves it from `PATH` (visible with `-v`).
- If `--workdir` is not provided, the command runs in the current directory.
- If a service with the same name already exists, uniservice asks whether to overwrite it.
- `add` performs `enable` + `start`, then prints the command, the working directory and the file it wrote.

Service names must not contain `/`, `\`, control characters or NUL, must not be `.`/`..`, and must not start with `-`
(those characters would escape the definition directory or break the native definition format).

### List

```bash
uniservice list              # a table on a terminal, TSV when piped
uniservice list --table      # force the table down a pipe:  uniservice list --table | less
uniservice list --json       # machine readable
uniservice list --quiet      # names only
```

The table has three columns:

| Column | Meaning |
| --- | --- |
| `NAME` | service name |
| `ENABLED` | starts automatically: `yes` / `no` / `?` |
| `RUNNING` | currently running: `yes` / `no` / `?` |

`?` means the platform did not give a definitive answer (for example `systemctl` is missing, the launchd override
query failed, or a unit is in a transitional state). It is never a guess. Names containing control characters are
sanitised and duplicate rows are collapsed, so the TSV form always stays valid.

When stdout is not a terminal the output is exactly the tab-separated
`NAME<TAB>ENABLED<TAB>RUNNING` contract, header included, so existing scripts keep
working unchanged.

### Control

```bash
uniservice start   demo
uniservice stop    demo
uniservice restart demo        # one native restart, not stop + start
uniservice enable  demo        # start at boot/login
uniservice disable demo
uniservice restart api worker  # several at once
```

### Status and logs

```bash
uniservice status              # a table of everything, with a summary line
uniservice status demo         # uniservice's verdict, then the native tool's own output
uniservice logs   demo         # last 200 lines
uniservice logs   demo -n 50
uniservice logs   demo -f      # follow
```

`status NAME` and `logs NAME` hand the terminal to the native tool on purpose: its output *is*
the answer, and reformatting `systemctl status` or `journalctl` would only lose detail.

### Show

```bash
uniservice show demo
```

```
demo · user scope
  command   /usr/bin/python3 -m http.server 8000
  workdir   /srv/demo
  location  /home/me/.config/systemd/user/uniservice-demo.service

recreate it with:
  uniservice add demo --workdir /srv/demo -- /usr/bin/python3 -m http.server 8000
```

`--json` gives the same fields, `recreate` included.

### Remove

```bash
uniservice remove demo
uniservice rm demo api         # stop, disable and delete several
```

`remove` performs `stop` + `disable` before deleting the definition.

### Doctor and version

```bash
uniservice doctor
```

```
uniservice doctor
  ✔ python           3.13.5 (/usr/bin/python3)
  ✔ platform         linux (linux)
  ✔ privileges       normal user · user scope
  ✔ installation     not managed by install.sh; that is fine for pipx, uv or a source checkout
  ✔ log file         /home/me/.uniservice/logs/uniservice.log
  ✔ systemctl        /usr/bin/systemctl
  ✔ journalctl       /usr/bin/journalctl
  ✔ systemd manager  running (systemctl --user is-system-running)
  ✔ unit directory   /home/me/.config/systemd/user

all good
```

`doctor` checks the interpreter, the platform, the privileges, the installation
and the log file, then asks the backend to probe the native supervisor: systemd
(including `is-system-running`, which is what fails inside a container that was
never booted with systemd), launchd's domains, or the Task Scheduler. A failing
check that is not fatal — an optional tool, an unwritable log file — is reported
as a warning and does not change the exit code.

`uniservice version` prints the versions of the pieces instead, and is what to
paste into a bug report.

### Output rules

| Flag | Effect |
| --- | --- |
| `--color=auto\|always\|never` | colour; `auto` is a terminal with `NO_COLOR` unset and `TERM` not `dumb` |
| `--ascii` | ASCII status marks and rules instead of `✔ ✘ • ─` |
| `--json` | one JSON document on stdout where the command supports it |
| `-q`, `--quiet` | only failures; `list` prints bare names |
| `-v`, `--verbose` | debug logging on stderr |

Colour and Unicode are decided once per run, so a redirected stdout never gets
ANSI escapes, and a Windows console on a legacy code page falls back to the ASCII
alphabet automatically.

## Logging

- Console: WARNING and above (`-v` lowers it to DEBUG), prefixed with `uniservice:`
- File: DEBUG and above, with timestamps
- Log file:
  - macOS/Linux: `~/.uniservice/logs/uniservice.log`
  - Windows: `%LOCALAPPDATA%\uniservice\logs\uniservice.log`

## Project layout

```
uniservice                  executable entry point (thin launcher, and what the zipapp runs)
uniservice_lib/
  cli.py                    entry point: parse, build the Context, dispatch, exit codes
  parser.py                 the command tree and the grouped help text
  console.py                the display layer: tty/colour/Unicode, tables, JSON
  exitcodes.py              exit code constants
  commands/
    __init__.py             Context: console + scope + backend, and the privilege rules
    service.py              list, add, start/stop/restart, enable/disable, remove, status, logs, show
    diagnostics.py          doctor, version
  scope.py                  user vs. system scope
  naming.py                 service-name validation and native definition names
  process.py                subprocess helpers
  platform_utils.py         platform and privilege detection
  logging_utils.py          console + file logging
  errors.py                 exception hierarchy
  installation.py           locating the copy and reading the installer's manifest
  backends/
    __init__.py             backend selection
    base.py                 Backend interface, ServiceInfo, ServiceDefinition, Check
    linux.py                systemd units
    macos.py                launchd jobs
    windows.py              Scheduled Tasks
scripts/build_zipapp.py     builds the portable zipapp (the recommended artifact)
scripts/build_binary.py     builds the standalone binary (PyInstaller, per platform)
packaging/entrypoint.py     PyInstaller entry point
install.sh                  macOS/Linux installer (artifact choice, manifest, --uninstall)
install-windows.ps1         Windows installer (zipapp by default, -Binary for the .exe)
tests/                      pytest suite (unit, end-to-end, installer, opt-in integration)
.github/workflows/          CI (3 platforms + 5 binary targets) and the release workflow
```

Each backend implements the same `Backend` interface, so adding a platform means adding one module and registering it
in `uniservice_lib/backends/__init__.py`.

Backends **return data** and the display layer renders it: `definition()` describes a service, `checks()` describes the
environment, `command_line()` spells a command the way the platform's shell needs. The only two methods that print are
`status()` and `logs()`, because there the native tool's own output is the answer.

## Development

```bash
python -m pip install -e ".[dev]"

ruff check .            # lint
ruff format --check .   # formatting
python -m pytest        # unit + end-to-end tests
```

Build both artifacts and try the installer against a throwaway prefix:

```bash
python scripts/build_zipapp.py --output dist/uniservice            # ~30 KB, needs Python
python -m pip install -e ".[build]"
python scripts/build_binary.py --output-dir dist                   # ~24 MB, self-contained

./install.sh --prefix /tmp/uniservice-test --from dist/uniservice
/tmp/uniservice-test/bin/uniservice --version
./install.sh --uninstall --prefix /tmp/uniservice-test
```

The zipapp build is deterministic, so `pytest tests/test_packaging.py tests/test_install_script.py` can compare a
locally built artifact against the digest the installer computes. The installer tests serve a fake release over
`file://`, so they exercise the download, the asset selection and the checksum verification without the network.

The default suite mocks the native tools, so it runs on every platform. Opt-in integration tests drive the real
service manager of the host:

```bash
UNISERVICE_RUN_INTEGRATION=1 python -m pytest -m integration -v
```

CI runs lint, shellcheck/PowerShell checks, the reproducible zipapp build, the full test suite on Ubuntu, macOS and
Windows, and builds the standalone binary for five targets. Pushing a `v*` tag runs
`.github/workflows/release.yml`, which publishes the wheel, the sdist, the zipapp, every platform binary and
`SHA256SUMS` as a GitHub release.

## Uninstall

The install script does it:

```bash
sudo ./install.sh --uninstall              # macOS/Linux
./install.sh --uninstall --prefix DIR      # if you installed to a custom prefix
./install.sh --uninstall --dry-run         # show what would be removed first
./install-windows.ps1 -Uninstall           # Windows
./install-windows.ps1 -Uninstall -DryRun   # ... and its preview
```

The one-liner form works too, since that is how most people install:

```bash
curl -fsSL https://raw.githubusercontent.com/kevinhuang001/uniservice/main/install.sh \
  | sudo bash -s -- --uninstall
```

It reads the manifest it wrote (`/usr/local/lib/uniservice/manifest`), deletes exactly those files
and prunes the directories that become empty. It never added a `PATH` line, so there is nothing else
to clean up.

**If uniservice came from pipx or uv, that is not the right command** - remove it with the frontend
that installed it (`pipx uninstall uniservice`). The installer only removes a copy it wrote itself,
which it knows from the manifest.

**Your services are untouched** - only the command is removed. Delete the services you no longer
want first, while the command still exists:

```bash
uniservice list
uniservice remove <name>
```

If you would rather remove it by hand, `uniservice version` and `uniservice doctor` print the prefix,
the kind and the manifest path; the manifest lists the files themselves.
