Spaces:
Build error
Build error
Commit ·
ff0bedf
1
Parent(s): ca89449
feat: harden persistent install wrappers
Browse filesTighten Docker-native bash and PowerShell wrapper validation for wrap and proxy flows, pin the bash wrapper to the install-time interpreter, clean up failed persistent container starts, and extend docs, CI, e2e, and native installer coverage for persistent Docker installs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- .github/workflows/ci.yml +60 -0
- docs/cli.md +6 -4
- docs/docker-install.md +32 -4
- docs/persistent-installs.md +7 -5
- e2e/docker-native-install.sh +81 -0
- scripts/install.ps1 +656 -30
- scripts/install.sh +627 -5
- tests/test_install/test_native_installers.py +731 -0
- tests/test_install/test_supervisors.py +94 -0
.github/workflows/ci.yml
CHANGED
|
@@ -101,6 +101,66 @@ jobs:
|
|
| 101 |
run: |
|
| 102 |
pytest tests/test_integrations/agno/ -v
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
build:
|
| 105 |
runs-on: ubuntu-latest
|
| 106 |
steps:
|
|
|
|
| 101 |
run: |
|
| 102 |
pytest tests/test_integrations/agno/ -v
|
| 103 |
|
| 104 |
+
docker-native-e2e:
|
| 105 |
+
runs-on: ubuntu-latest
|
| 106 |
+
steps:
|
| 107 |
+
- uses: actions/checkout@v4
|
| 108 |
+
|
| 109 |
+
- name: Set up Python
|
| 110 |
+
uses: actions/setup-python@v5
|
| 111 |
+
with:
|
| 112 |
+
python-version: "3.11"
|
| 113 |
+
|
| 114 |
+
- name: Build local Headroom image
|
| 115 |
+
run: |
|
| 116 |
+
docker build -t headroom-native-e2e:latest .
|
| 117 |
+
|
| 118 |
+
- name: Run Docker-native installer e2e
|
| 119 |
+
env:
|
| 120 |
+
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
|
| 121 |
+
run: |
|
| 122 |
+
bash e2e/docker-native-install.sh
|
| 123 |
+
|
| 124 |
+
windows-native-wrapper:
|
| 125 |
+
runs-on: windows-latest
|
| 126 |
+
steps:
|
| 127 |
+
- uses: actions/checkout@v4
|
| 128 |
+
|
| 129 |
+
- name: Set up Python
|
| 130 |
+
uses: actions/setup-python@v5
|
| 131 |
+
with:
|
| 132 |
+
python-version: "3.12"
|
| 133 |
+
|
| 134 |
+
- name: Install test dependencies
|
| 135 |
+
run: |
|
| 136 |
+
python -m pip install --upgrade pip
|
| 137 |
+
pip install pytest
|
| 138 |
+
|
| 139 |
+
- name: Run native installer wrapper tests
|
| 140 |
+
run: |
|
| 141 |
+
pytest tests/test_install/test_native_installers.py -q
|
| 142 |
+
|
| 143 |
+
macos-native-wrapper:
|
| 144 |
+
runs-on: macos-latest
|
| 145 |
+
steps:
|
| 146 |
+
- uses: actions/checkout@v4
|
| 147 |
+
|
| 148 |
+
- name: Set up Python
|
| 149 |
+
uses: actions/setup-python@v5
|
| 150 |
+
with:
|
| 151 |
+
python-version: "3.11"
|
| 152 |
+
|
| 153 |
+
- name: Install bash and test dependencies
|
| 154 |
+
run: |
|
| 155 |
+
brew install bash
|
| 156 |
+
python -m pip install --upgrade pip
|
| 157 |
+
pip install pytest
|
| 158 |
+
|
| 159 |
+
- name: Run native installer wrapper tests
|
| 160 |
+
run: |
|
| 161 |
+
export PATH="$(brew --prefix bash)/bin:$PATH"
|
| 162 |
+
pytest tests/test_install/test_native_installers.py -q
|
| 163 |
+
|
| 164 |
build:
|
| 165 |
runs-on: ubuntu-latest
|
| 166 |
steps:
|
docs/cli.md
CHANGED
|
@@ -22,7 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|
| 22 |
|
| 23 |
| Command | Purpose | Docker-native parity |
|
| 24 |
|---|---|---|
|
| 25 |
-
| `headroom install ...` | Install and manage persistent deployments | **python-native
|
| 26 |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
|
| 27 |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
|
| 28 |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
|
|
@@ -30,7 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
|
|
| 30 |
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
|
| 31 |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
|
| 32 |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
|
| 33 |
-
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **
|
| 34 |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
|
| 35 |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
|
| 36 |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
|
|
@@ -630,6 +630,8 @@ headroom install apply --preset persistent-docker --scope user
|
|
| 630 |
|
| 631 |
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
|
| 632 |
|
|
|
|
|
|
|
| 633 |
### `headroom install status`
|
| 634 |
|
| 635 |
```bash
|
|
@@ -852,9 +854,9 @@ Legend:
|
|
| 852 |
| `headroom mcp uninstall` | native | native in container | full |
|
| 853 |
| `headroom mcp status` | native | native in container | full |
|
| 854 |
| `headroom mcp serve` | native | native in container | full |
|
| 855 |
-
| `headroom install
|
| 856 |
| `headroom wrap claude` | native | host-bridged | partial |
|
| 857 |
-
| `headroom wrap copilot` | native |
|
| 858 |
| `headroom wrap codex` | native | host-bridged | partial |
|
| 859 |
| `headroom wrap aider` | native | host-bridged | partial |
|
| 860 |
| `headroom wrap cursor` | native | host-bridged | partial |
|
|
|
|
| 22 |
|
| 23 |
| Command | Purpose | Docker-native parity |
|
| 24 |
|---|---|---|
|
| 25 |
+
| `headroom install ...` | Install and manage persistent deployments | **python-native; Docker-native wrapper supports `persistent-docker` lifecycle subset** |
|
| 26 |
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
|
| 27 |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
|
| 28 |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
|
|
|
|
| 30 |
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
|
| 31 |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
|
| 32 |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
|
| 33 |
+
| `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **python-native only** |
|
| 34 |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
|
| 35 |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
|
| 36 |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
|
|
|
|
| 630 |
|
| 631 |
`apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
|
| 632 |
|
| 633 |
+
Docker-native host wrappers expose a narrower `headroom install` subset for `persistent-docker` only: `apply`, `status`, `start`, `stop`, `restart`, and `remove`. Those wrapper flows preserve the same port and manifest behavior, but they intentionally reject `persistent-service`, `persistent-task`, and provider mutation flags like `--scope`, `--providers`, and `--target`.
|
| 634 |
+
|
| 635 |
### `headroom install status`
|
| 636 |
|
| 637 |
```bash
|
|
|
|
| 854 |
| `headroom mcp uninstall` | native | native in container | full |
|
| 855 |
| `headroom mcp status` | native | native in container | full |
|
| 856 |
| `headroom mcp serve` | native | native in container | full |
|
| 857 |
+
| `headroom install apply|status|start|stop|restart|remove` | native | Docker-native wrapper for `persistent-docker`; compose remains an alternative | partial |
|
| 858 |
| `headroom wrap claude` | native | host-bridged | partial |
|
| 859 |
+
| `headroom wrap copilot` | native | not implemented in Docker-native wrapper | none |
|
| 860 |
| `headroom wrap codex` | native | host-bridged | partial |
|
| 861 |
| `headroom wrap aider` | native | host-bridged | partial |
|
| 862 |
| `headroom wrap cursor` | native | host-bridged | partial |
|
docs/docker-install.md
CHANGED
|
@@ -4,12 +4,20 @@ Run Headroom without installing Python or Node.js on the host. The install scrip
|
|
| 4 |
|
| 5 |
## One-line install
|
| 6 |
|
| 7 |
-
###
|
| 8 |
|
| 9 |
```bash
|
| 10 |
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
|
| 11 |
```
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
### Windows PowerShell
|
| 14 |
|
| 15 |
```powershell
|
|
@@ -19,7 +27,7 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
|
|
| 19 |
## What the installer does
|
| 20 |
|
| 21 |
1. Verifies Docker is installed and available.
|
| 22 |
-
2. Pulls `ghcr.io/chopratejas/headroom:latest`.
|
| 23 |
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
|
| 24 |
4. Updates shell startup files so the wrapper directory is on `PATH`.
|
| 25 |
|
|
@@ -81,9 +89,28 @@ OpenClaw remains host-native in Docker-native mode:
|
|
| 81 |
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
|
| 82 |
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
## Docker Compose support
|
| 85 |
|
| 86 |
-
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
|
| 87 |
|
| 88 |
### Persistent Docker runtime
|
| 89 |
|
|
@@ -101,7 +128,7 @@ $env:HEADROOM_WORKSPACE = (Get-Location).Path
|
|
| 101 |
docker compose -f docker/docker-compose.native.yml up -d proxy
|
| 102 |
```
|
| 103 |
|
| 104 |
-
This
|
| 105 |
|
| 106 |
### macOS / Linux
|
| 107 |
|
|
@@ -147,3 +174,4 @@ That keeps provider auth and runtime config working without maintaining a separa
|
|
| 147 |
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
|
| 148 |
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
|
| 149 |
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
|
|
|
|
|
|
| 4 |
|
| 5 |
## One-line install
|
| 6 |
|
| 7 |
+
### Linux
|
| 8 |
|
| 9 |
```bash
|
| 10 |
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
|
| 11 |
```
|
| 12 |
|
| 13 |
+
### macOS (bash 4.3+)
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | "$(brew --prefix bash)/bin/bash"
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Stock `/bin/bash` on macOS is 3.2, so install a newer bash first (for example via Homebrew) and run the installer with that shell. The installed wrapper pins that same bash interpreter so later invocations stay on the supported runtime.
|
| 20 |
+
|
| 21 |
### Windows PowerShell
|
| 22 |
|
| 23 |
```powershell
|
|
|
|
| 27 |
## What the installer does
|
| 28 |
|
| 29 |
1. Verifies Docker is installed and available.
|
| 30 |
+
2. Pulls `ghcr.io/chopratejas/headroom:latest` by default, or reuses / pulls `HEADROOM_DOCKER_IMAGE` when you set a custom image override.
|
| 31 |
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
|
| 32 |
4. Updates shell startup files so the wrapper directory is on `PATH`.
|
| 33 |
|
|
|
|
| 89 |
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
|
| 90 |
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
|
| 91 |
|
| 92 |
+
## Persistent Docker lifecycle from the native wrapper
|
| 93 |
+
|
| 94 |
+
The Docker-native `headroom` wrapper now exposes the persistent Docker lifecycle directly:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
headroom install apply --profile default --preset persistent-docker
|
| 98 |
+
headroom install status
|
| 99 |
+
headroom install restart
|
| 100 |
+
headroom install remove
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
In Docker-native mode this surface is intentionally scoped to **persistent-docker**:
|
| 104 |
+
|
| 105 |
+
- supported: `apply`, `status`, `start`, `stop`, `restart`, `remove`
|
| 106 |
+
- supported flags: `--profile`, `--port`, `--backend`, `--anyllm-provider`, `--region`, `--mode`, `--memory`, `--no-telemetry`, `--image`
|
| 107 |
+
- not supported: `persistent-service`, `persistent-task`, or provider/user/system mutation flags such as `--scope`, `--providers`, and `--target`
|
| 108 |
+
|
| 109 |
+
Those broader lifecycle and config-mutation flows still belong to the Python-native `headroom install ...` command.
|
| 110 |
+
|
| 111 |
## Docker Compose support
|
| 112 |
|
| 113 |
+
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell, or when you prefer compose over the native wrapper's `headroom install ...` surface.
|
| 114 |
|
| 115 |
### Persistent Docker runtime
|
| 116 |
|
|
|
|
| 128 |
docker compose -f docker/docker-compose.native.yml up -d proxy
|
| 129 |
```
|
| 130 |
|
| 131 |
+
This remains a supported persistent-Docker path when you want the proxy managed explicitly through Compose instead of the installed wrapper.
|
| 132 |
|
| 133 |
### macOS / Linux
|
| 134 |
|
|
|
|
| 174 |
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
|
| 175 |
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
|
| 176 |
- For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
|
| 177 |
+
- For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy/<profile>/`.
|
docs/persistent-installs.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
|
| 4 |
|
| 5 |
-
Use `headroom install` when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
|
| 6 |
|
| 7 |
## Runtime matrix
|
| 8 |
|
|
@@ -43,7 +43,7 @@ headroom install apply --preset persistent-docker --scope user --providers auto
|
|
| 43 |
|
| 44 |
This uses Docker's restart policy instead of an OS supervisor.
|
| 45 |
|
| 46 |
-
If you are using the Docker-native host wrapper instead of a Python install, use the
|
| 47 |
|
| 48 |
## Command surface
|
| 49 |
|
|
@@ -125,14 +125,16 @@ Persistent deployments publish the same `readyz` and `health` endpoints as ad ho
|
|
| 125 |
}
|
| 126 |
```
|
| 127 |
|
| 128 |
-
`headroom wrap ...` checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy,
|
|
|
|
|
|
|
| 129 |
|
| 130 |
## Docker-native relationship
|
| 131 |
|
| 132 |
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
|
| 133 |
|
| 134 |
-
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI
|
| 135 |
-
- `headroom install ...` -> persistent service, task, and
|
| 136 |
|
| 137 |
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
|
| 138 |
|
|
|
|
| 2 |
|
| 3 |
Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
|
| 4 |
|
| 5 |
+
Use the Python-native `headroom install` CLI when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
|
| 6 |
|
| 7 |
## Runtime matrix
|
| 8 |
|
|
|
|
| 43 |
|
| 44 |
This uses Docker's restart policy instead of an OS supervisor.
|
| 45 |
|
| 46 |
+
If you are using the Docker-native host wrapper instead of a Python install, you can now use `headroom install apply|status|start|stop|restart|remove` for the `persistent-docker` preset directly from the installed wrapper. Service/task installs and provider/user/system mutation flows still belong to the Python-native CLI.
|
| 47 |
|
| 48 |
## Command surface
|
| 49 |
|
|
|
|
| 125 |
}
|
| 126 |
```
|
| 127 |
|
| 128 |
+
The Python-native `headroom wrap ...` flow checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, it attempts to recover it first.
|
| 129 |
+
|
| 130 |
+
The Docker-native host wrapper does **not** yet reuse or recover persistent profiles automatically; it still starts a fresh proxy container unless you opt into `--no-proxy`.
|
| 131 |
|
| 132 |
## Docker-native relationship
|
| 133 |
|
| 134 |
The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
|
| 135 |
|
| 136 |
+
- [Docker-Native Install](docker-install.md) -> containerized on-demand CLI, wrapped host-tool flows, and Docker-native `persistent-docker` lifecycle commands
|
| 137 |
+
- `headroom install ...` -> full persistent service, task, and Docker lifecycle management, including provider/user/system mutation
|
| 138 |
|
| 139 |
For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
|
| 140 |
|
e2e/docker-native-install.sh
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 6 |
+
IMAGE="${HEADROOM_DOCKER_IMAGE:?set HEADROOM_DOCKER_IMAGE to a built test image}"
|
| 7 |
+
PROFILE="ci-smoke"
|
| 8 |
+
TMP_HOME="$(mktemp -d)"
|
| 9 |
+
PORT="$(python3 - <<'PY'
|
| 10 |
+
import socket
|
| 11 |
+
|
| 12 |
+
with socket.socket() as sock:
|
| 13 |
+
sock.bind(("127.0.0.1", 0))
|
| 14 |
+
print(sock.getsockname()[1])
|
| 15 |
+
PY
|
| 16 |
+
)"
|
| 17 |
+
|
| 18 |
+
cleanup() {
|
| 19 |
+
docker rm -f "headroom-${PROFILE}" >/dev/null 2>&1 || true
|
| 20 |
+
rm -rf "${TMP_HOME}"
|
| 21 |
+
}
|
| 22 |
+
trap cleanup EXIT
|
| 23 |
+
|
| 24 |
+
mkdir -p "${TMP_HOME}/.local"
|
| 25 |
+
export HOME="${TMP_HOME}"
|
| 26 |
+
export PATH="${HOME}/.local/bin:${PATH}"
|
| 27 |
+
|
| 28 |
+
bash "${ROOT_DIR}/scripts/install.sh"
|
| 29 |
+
|
| 30 |
+
WRAPPER="${HOME}/.local/bin/headroom"
|
| 31 |
+
[[ -x "${WRAPPER}" ]]
|
| 32 |
+
|
| 33 |
+
"${WRAPPER}" install -? | grep -Fq "persistent-docker preset only"
|
| 34 |
+
|
| 35 |
+
"${WRAPPER}" install apply \
|
| 36 |
+
--profile "${PROFILE}" \
|
| 37 |
+
--port "${PORT}" \
|
| 38 |
+
--image "${IMAGE}" \
|
| 39 |
+
--no-telemetry
|
| 40 |
+
|
| 41 |
+
status_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
| 42 |
+
printf '%s\n' "${status_output}"
|
| 43 |
+
grep -Fq "Status: running" <<<"${status_output}"
|
| 44 |
+
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
| 45 |
+
|
| 46 |
+
python3 - <<'PY' "${HOME}" "${PROFILE}" "${PORT}"
|
| 47 |
+
import json
|
| 48 |
+
import sys
|
| 49 |
+
from pathlib import Path
|
| 50 |
+
|
| 51 |
+
home = Path(sys.argv[1])
|
| 52 |
+
profile = sys.argv[2]
|
| 53 |
+
port = int(sys.argv[3])
|
| 54 |
+
manifest = json.loads((home / ".headroom" / "deploy" / profile / "manifest.json").read_text())
|
| 55 |
+
assert manifest["preset"] == "persistent-docker"
|
| 56 |
+
assert manifest["port"] == port
|
| 57 |
+
assert manifest["telemetry_enabled"] is False
|
| 58 |
+
PY
|
| 59 |
+
|
| 60 |
+
if apply_error="$("${WRAPPER}" install apply --scope user 2>&1)"; then
|
| 61 |
+
echo "expected docker-native install apply --scope user to fail" >&2
|
| 62 |
+
exit 1
|
| 63 |
+
fi
|
| 64 |
+
grep -Fq "does not support provider/user/system mutation flags" <<<"${apply_error}"
|
| 65 |
+
|
| 66 |
+
"${WRAPPER}" install stop --profile "${PROFILE}"
|
| 67 |
+
stopped_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
| 68 |
+
printf '%s\n' "${stopped_output}"
|
| 69 |
+
grep -Fq "Status: stopped" <<<"${stopped_output}"
|
| 70 |
+
|
| 71 |
+
"${WRAPPER}" install start --profile "${PROFILE}"
|
| 72 |
+
started_output="$("${WRAPPER}" install status --profile "${PROFILE}")"
|
| 73 |
+
printf '%s\n' "${started_output}"
|
| 74 |
+
grep -Fq "Status: running" <<<"${started_output}"
|
| 75 |
+
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
| 76 |
+
|
| 77 |
+
"${WRAPPER}" install restart --profile "${PROFILE}"
|
| 78 |
+
curl --fail --silent "http://127.0.0.1:${PORT}/readyz" >/dev/null
|
| 79 |
+
|
| 80 |
+
"${WRAPPER}" install remove --profile "${PROFILE}"
|
| 81 |
+
[[ ! -e "${HOME}/.headroom/deploy/${PROFILE}" ]]
|
scripts/install.ps1
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
$ErrorActionPreference = 'Stop'
|
| 2 |
|
| 3 |
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
|
|
|
|
| 4 |
$InstallDir = Join-Path $HOME '.local\bin'
|
| 5 |
if (-not (Test-Path (Join-Path $HOME '.local'))) {
|
| 6 |
$InstallDir = Join-Path $HOME 'bin'
|
|
@@ -37,10 +38,11 @@ function Ensure-ProfileBlock {
|
|
| 37 |
|
| 38 |
$markerStart = '# >>> headroom docker-native >>>'
|
| 39 |
$markerEnd = '# <<< headroom docker-native <<<'
|
|
|
|
| 40 |
$block = @"
|
| 41 |
$markerStart
|
| 42 |
-
if (-not ((`$env:Path -split ';') -contains '$
|
| 43 |
-
`$env:Path = '$
|
| 44 |
}
|
| 45 |
$markerEnd
|
| 46 |
"@
|
|
@@ -151,11 +153,27 @@ function Get-SharedDockerArgs {
|
|
| 151 |
return ,$args.ToArray()
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
function Invoke-HeadroomDocker {
|
| 155 |
param([string[]]$Arguments)
|
| 156 |
|
| 157 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 158 |
-
$dockerArgs.AddRange([string[]]@('run','--rm'
|
|
|
|
| 159 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 160 |
$dockerArgs.Add('--entrypoint')
|
| 161 |
$dockerArgs.Add('headroom')
|
|
@@ -228,6 +246,538 @@ function Stop-ProxyContainer {
|
|
| 228 |
}
|
| 229 |
}
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
function Invoke-ClaudeRtkInit {
|
| 232 |
$rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
|
| 233 |
if (-not (Test-Path $rtkPath)) {
|
|
@@ -300,6 +850,7 @@ function Parse-OpenClawWrapArgs {
|
|
| 300 |
$arg = $Arguments[$i]
|
| 301 |
switch -Regex ($arg) {
|
| 302 |
'^--plugin-path$' {
|
|
|
|
| 303 |
$pluginPath = $Arguments[$i + 1]
|
| 304 |
$i += 2
|
| 305 |
continue
|
|
@@ -310,6 +861,7 @@ function Parse-OpenClawWrapArgs {
|
|
| 310 |
continue
|
| 311 |
}
|
| 312 |
'^--plugin-spec$' {
|
|
|
|
| 313 |
$pluginSpec = $Arguments[$i + 1]
|
| 314 |
$i += 2
|
| 315 |
continue
|
|
@@ -330,26 +882,29 @@ function Parse-OpenClawWrapArgs {
|
|
| 330 |
continue
|
| 331 |
}
|
| 332 |
'^--proxy-port$' {
|
| 333 |
-
|
|
|
|
| 334 |
$i += 2
|
| 335 |
continue
|
| 336 |
}
|
| 337 |
'^--proxy-port=' {
|
| 338 |
-
$proxyPort =
|
| 339 |
$i += 1
|
| 340 |
continue
|
| 341 |
}
|
| 342 |
'^--startup-timeout-ms$' {
|
| 343 |
-
|
|
|
|
| 344 |
$i += 2
|
| 345 |
continue
|
| 346 |
}
|
| 347 |
'^--startup-timeout-ms=' {
|
| 348 |
-
$startupTimeoutMs =
|
| 349 |
$i += 1
|
| 350 |
continue
|
| 351 |
}
|
| 352 |
'^--gateway-provider-id$' {
|
|
|
|
| 353 |
$gatewayProviderIds.Add($Arguments[$i + 1])
|
| 354 |
$i += 2
|
| 355 |
continue
|
|
@@ -360,6 +915,7 @@ function Parse-OpenClawWrapArgs {
|
|
| 360 |
continue
|
| 361 |
}
|
| 362 |
'^--python-path$' {
|
|
|
|
| 363 |
$pythonPath = $Arguments[$i + 1]
|
| 364 |
$i += 2
|
| 365 |
continue
|
|
@@ -762,7 +1318,7 @@ function Parse-WrapArgs {
|
|
| 762 |
param([string[]]$Arguments)
|
| 763 |
|
| 764 |
$known = New-Object System.Collections.Generic.List[string]
|
| 765 |
-
$
|
| 766 |
$port = 8787
|
| 767 |
$noRtk = $false
|
| 768 |
$noProxy = $false
|
|
@@ -777,20 +1333,21 @@ function Parse-WrapArgs {
|
|
| 777 |
switch -Regex ($arg) {
|
| 778 |
'^--$' {
|
| 779 |
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
|
| 780 |
-
$
|
| 781 |
}
|
| 782 |
$i = $Arguments.Count
|
| 783 |
continue
|
| 784 |
}
|
| 785 |
'^--port$|^-p$' {
|
| 786 |
-
|
|
|
|
| 787 |
$known.Add($arg)
|
| 788 |
$known.Add($Arguments[$i + 1])
|
| 789 |
$i += 2
|
| 790 |
continue
|
| 791 |
}
|
| 792 |
'^--port=' {
|
| 793 |
-
$port =
|
| 794 |
$known.Add($arg)
|
| 795 |
$i += 1
|
| 796 |
continue
|
|
@@ -819,6 +1376,7 @@ function Parse-WrapArgs {
|
|
| 819 |
continue
|
| 820 |
}
|
| 821 |
'^--backend$' {
|
|
|
|
| 822 |
$backend = $Arguments[$i + 1]
|
| 823 |
$known.Add($arg)
|
| 824 |
$known.Add($Arguments[$i + 1])
|
|
@@ -832,6 +1390,7 @@ function Parse-WrapArgs {
|
|
| 832 |
continue
|
| 833 |
}
|
| 834 |
'^--anyllm-provider$' {
|
|
|
|
| 835 |
$anyllm = $Arguments[$i + 1]
|
| 836 |
$known.Add($arg)
|
| 837 |
$known.Add($Arguments[$i + 1])
|
|
@@ -845,6 +1404,7 @@ function Parse-WrapArgs {
|
|
| 845 |
continue
|
| 846 |
}
|
| 847 |
'^--region$' {
|
|
|
|
| 848 |
$region = $Arguments[$i + 1]
|
| 849 |
$known.Add($arg)
|
| 850 |
$known.Add($Arguments[$i + 1])
|
|
@@ -859,7 +1419,7 @@ function Parse-WrapArgs {
|
|
| 859 |
}
|
| 860 |
default {
|
| 861 |
for ($j = $i; $j -lt $Arguments.Count; $j++) {
|
| 862 |
-
$
|
| 863 |
}
|
| 864 |
$i = $Arguments.Count
|
| 865 |
}
|
|
@@ -868,7 +1428,7 @@ function Parse-WrapArgs {
|
|
| 868 |
|
| 869 |
[pscustomobject]@{
|
| 870 |
KnownArgs = $known.ToArray()
|
| 871 |
-
HostArgs = $
|
| 872 |
Port = $port
|
| 873 |
NoRtk = $noRtk
|
| 874 |
NoProxy = $noProxy
|
|
@@ -886,7 +1446,8 @@ function Invoke-PrepareOnly {
|
|
| 886 |
)
|
| 887 |
|
| 888 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 889 |
-
$dockerArgs.AddRange([string[]]@('run','--rm'
|
|
|
|
| 890 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 891 |
$dockerArgs.Add('--env')
|
| 892 |
$dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
|
|
@@ -912,6 +1473,58 @@ if ($args.Count -eq 0) {
|
|
| 912 |
}
|
| 913 |
|
| 914 |
switch ($args[0]) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
'wrap' {
|
| 916 |
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
|
| 917 |
Invoke-HeadroomDocker -Arguments @('wrap','--help')
|
|
@@ -1028,17 +1641,20 @@ switch ($args[0]) {
|
|
| 1028 |
foreach ($arg in $args) { $forwardArgs.Add($arg) }
|
| 1029 |
for ($i = 1; $i -lt $args.Count; $i++) {
|
| 1030 |
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
|
| 1031 |
-
|
|
|
|
| 1032 |
break
|
| 1033 |
}
|
| 1034 |
if ($args[$i] -match '^--port=') {
|
| 1035 |
-
$port =
|
| 1036 |
break
|
| 1037 |
}
|
| 1038 |
}
|
| 1039 |
|
| 1040 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 1041 |
-
$dockerArgs.AddRange([string[]]@('run','--rm'
|
|
|
|
|
|
|
| 1042 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 1043 |
$dockerArgs.Add('--entrypoint')
|
| 1044 |
$dockerArgs.Add('headroom')
|
|
@@ -1056,34 +1672,44 @@ switch ($args[0]) {
|
|
| 1056 |
}
|
| 1057 |
'@
|
| 1058 |
|
| 1059 |
-
$cmdWrapper =
|
| 1060 |
-
@echo off
|
| 1061 |
-
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0headroom.ps1" %*
|
| 1062 |
-
'@
|
| 1063 |
|
| 1064 |
-
Set-Content -Path $wrapperPath -Value $wrapper
|
| 1065 |
-
Set-Content -Path $cmdPath -Value $cmdWrapper
|
| 1066 |
}
|
| 1067 |
|
| 1068 |
Require-Command docker
|
| 1069 |
docker version | Out-Null
|
|
|
|
|
|
|
|
|
|
| 1070 |
|
| 1071 |
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
| 1072 |
Write-Wrapper -TargetDir $InstallDir
|
| 1073 |
Ensure-PathEntry -PathEntry $InstallDir
|
| 1074 |
Ensure-ProfileBlock -PathEntry $InstallDir
|
| 1075 |
|
| 1076 |
-
|
| 1077 |
-
docker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
|
| 1079 |
-
Write-Host
|
| 1080 |
-
Write-Host
|
| 1081 |
-
Write-Host
|
| 1082 |
Write-Host "Installed wrappers:"
|
| 1083 |
Write-Host " $InstallDir\headroom.ps1"
|
| 1084 |
Write-Host " $InstallDir\headroom.cmd"
|
| 1085 |
-
Write-Host
|
| 1086 |
-
Write-Host
|
| 1087 |
Write-Host " 1. Restart PowerShell"
|
| 1088 |
Write-Host " 2. Try: headroom proxy"
|
| 1089 |
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"
|
|
|
|
| 1 |
$ErrorActionPreference = 'Stop'
|
| 2 |
|
| 3 |
$ImageDefault = 'ghcr.io/chopratejas/headroom:latest'
|
| 4 |
+
$InstallImage = if ($env:HEADROOM_DOCKER_IMAGE) { $env:HEADROOM_DOCKER_IMAGE } else { $ImageDefault }
|
| 5 |
$InstallDir = Join-Path $HOME '.local\bin'
|
| 6 |
if (-not (Test-Path (Join-Path $HOME '.local'))) {
|
| 7 |
$InstallDir = Join-Path $HOME 'bin'
|
|
|
|
| 38 |
|
| 39 |
$markerStart = '# >>> headroom docker-native >>>'
|
| 40 |
$markerEnd = '# <<< headroom docker-native <<<'
|
| 41 |
+
$escapedPathEntry = $PathEntry.Replace("'", "''")
|
| 42 |
$block = @"
|
| 43 |
$markerStart
|
| 44 |
+
if (-not ((`$env:Path -split ';') -contains '$escapedPathEntry')) {
|
| 45 |
+
`$env:Path = '$escapedPathEntry;' + `$env:Path
|
| 46 |
}
|
| 47 |
$markerEnd
|
| 48 |
"@
|
|
|
|
| 153 |
return ,$args.ToArray()
|
| 154 |
}
|
| 155 |
|
| 156 |
+
function Add-TtyArgs {
|
| 157 |
+
param($ArgsList)
|
| 158 |
+
|
| 159 |
+
if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) {
|
| 160 |
+
$ArgsList.Add('-it')
|
| 161 |
+
return
|
| 162 |
+
}
|
| 163 |
+
if (-not [Console]::IsInputRedirected) {
|
| 164 |
+
$ArgsList.Add('-i')
|
| 165 |
+
}
|
| 166 |
+
if (-not [Console]::IsOutputRedirected) {
|
| 167 |
+
$ArgsList.Add('-t')
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
function Invoke-HeadroomDocker {
|
| 172 |
param([string[]]$Arguments)
|
| 173 |
|
| 174 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 175 |
+
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
| 176 |
+
Add-TtyArgs -ArgsList $dockerArgs
|
| 177 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 178 |
$dockerArgs.Add('--entrypoint')
|
| 179 |
$dockerArgs.Add('headroom')
|
|
|
|
| 246 |
}
|
| 247 |
}
|
| 248 |
|
| 249 |
+
function Get-PersistentProfileRoot {
|
| 250 |
+
param([string]$Profile)
|
| 251 |
+
Assert-ValidProfileName -Profile $Profile
|
| 252 |
+
return Join-Path (Join-Path $HostHome '.headroom\deploy') $Profile
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function Get-PersistentStatePath {
|
| 256 |
+
param([string]$Profile)
|
| 257 |
+
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'docker-native.json'
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function Get-PersistentManifestPath {
|
| 261 |
+
param([string]$Profile)
|
| 262 |
+
return Join-Path (Get-PersistentProfileRoot -Profile $Profile) 'manifest.json'
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function Get-PersistentContainerName {
|
| 266 |
+
param([string]$Profile)
|
| 267 |
+
return "headroom-$Profile"
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function Assert-ValidProfileName {
|
| 271 |
+
param([string]$Profile)
|
| 272 |
+
if ($Profile -notmatch '^[A-Za-z0-9._-]+$' -or $Profile -in @('.', '..')) {
|
| 273 |
+
Fail "Invalid profile name '$Profile'"
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function Parse-PortValue {
|
| 278 |
+
param([string]$Value)
|
| 279 |
+
|
| 280 |
+
$parsed = 0
|
| 281 |
+
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 65535) {
|
| 282 |
+
Fail "Invalid port '$Value'"
|
| 283 |
+
}
|
| 284 |
+
return $parsed
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function Parse-PositiveIntegerValue {
|
| 288 |
+
param([string]$Value)
|
| 289 |
+
|
| 290 |
+
$parsed = 0
|
| 291 |
+
if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) {
|
| 292 |
+
Fail "Invalid value '$Value'"
|
| 293 |
+
}
|
| 294 |
+
return $parsed
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function Require-OptionValue {
|
| 298 |
+
param(
|
| 299 |
+
[string[]]$Arguments,
|
| 300 |
+
[int]$Index,
|
| 301 |
+
[string]$Option
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if ($Index + 1 -ge $Arguments.Count) {
|
| 305 |
+
Fail "Option $Option requires a value"
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function Get-PersistentDockerArgs {
|
| 310 |
+
Ensure-HostDirs
|
| 311 |
+
$args = New-Object System.Collections.Generic.List[string]
|
| 312 |
+
$args.Add('--workdir')
|
| 313 |
+
$args.Add($ContainerHome)
|
| 314 |
+
$args.Add('--env')
|
| 315 |
+
$args.Add("HOME=$ContainerHome")
|
| 316 |
+
$args.Add('--env')
|
| 317 |
+
$args.Add('PYTHONUNBUFFERED=1')
|
| 318 |
+
$args.Add('--volume')
|
| 319 |
+
$args.Add((Join-Path $HostHome '.headroom') + ":$ContainerHome/.headroom")
|
| 320 |
+
$args.Add('--volume')
|
| 321 |
+
$args.Add((Join-Path $HostHome '.claude') + ":$ContainerHome/.claude")
|
| 322 |
+
$args.Add('--volume')
|
| 323 |
+
$args.Add((Join-Path $HostHome '.codex') + ":$ContainerHome/.codex")
|
| 324 |
+
$args.Add('--volume')
|
| 325 |
+
$args.Add((Join-Path $HostHome '.gemini') + ":$ContainerHome/.gemini")
|
| 326 |
+
|
| 327 |
+
foreach ($entry in (Get-PassthroughEnvArgs)) {
|
| 328 |
+
$args.Add($entry)
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return ,$args.ToArray()
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function Get-ManifestProxyArgs {
|
| 335 |
+
param(
|
| 336 |
+
[int]$Port,
|
| 337 |
+
[string]$Backend,
|
| 338 |
+
[string]$AnyllmProvider,
|
| 339 |
+
[string]$Region,
|
| 340 |
+
[string]$Mode,
|
| 341 |
+
[bool]$Memory,
|
| 342 |
+
[bool]$TelemetryEnabled
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
$args = New-Object System.Collections.Generic.List[string]
|
| 346 |
+
$args.AddRange([string[]]@('--host','127.0.0.1','--port',"$Port",'--mode',$Mode,'--backend',$Backend))
|
| 347 |
+
if (-not $TelemetryEnabled) {
|
| 348 |
+
$args.Add('--no-telemetry')
|
| 349 |
+
}
|
| 350 |
+
if ($Memory) {
|
| 351 |
+
$args.AddRange([string[]]@('--memory','--memory-db-path',"$ContainerHome/.headroom/memory.db"))
|
| 352 |
+
}
|
| 353 |
+
if ($AnyllmProvider) {
|
| 354 |
+
$args.AddRange([string[]]@('--anyllm-provider', $AnyllmProvider))
|
| 355 |
+
}
|
| 356 |
+
if ($Region) {
|
| 357 |
+
$args.AddRange([string[]]@('--region', $Region))
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
return ,$args.ToArray()
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function Write-PersistentState {
|
| 364 |
+
param(
|
| 365 |
+
[string]$Profile,
|
| 366 |
+
[string]$Image,
|
| 367 |
+
[int]$Port,
|
| 368 |
+
[string]$Backend,
|
| 369 |
+
[string]$AnyllmProvider,
|
| 370 |
+
[string]$Region,
|
| 371 |
+
[string]$Mode,
|
| 372 |
+
[bool]$Memory,
|
| 373 |
+
[bool]$TelemetryEnabled
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
$root = Get-PersistentProfileRoot -Profile $Profile
|
| 377 |
+
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
| 378 |
+
$state = [ordered]@{
|
| 379 |
+
profile = $Profile
|
| 380 |
+
image = $Image
|
| 381 |
+
port = $Port
|
| 382 |
+
backend = $Backend
|
| 383 |
+
anyllm_provider = $AnyllmProvider
|
| 384 |
+
region = $Region
|
| 385 |
+
proxy_mode = $Mode
|
| 386 |
+
memory_enabled = $Memory
|
| 387 |
+
telemetry_enabled = $TelemetryEnabled
|
| 388 |
+
container_name = Get-PersistentContainerName -Profile $Profile
|
| 389 |
+
health_url = "http://127.0.0.1:$Port/readyz"
|
| 390 |
+
}
|
| 391 |
+
$state | ConvertTo-Json -Depth 4 | Set-Content -Path (Get-PersistentStatePath -Profile $Profile) -Encoding utf8
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
function Write-PersistentManifest {
|
| 395 |
+
param(
|
| 396 |
+
[string]$Profile,
|
| 397 |
+
[string]$Image,
|
| 398 |
+
[int]$Port,
|
| 399 |
+
[string]$Backend,
|
| 400 |
+
[string]$AnyllmProvider,
|
| 401 |
+
[string]$Region,
|
| 402 |
+
[string]$Mode,
|
| 403 |
+
[bool]$Memory,
|
| 404 |
+
[bool]$TelemetryEnabled,
|
| 405 |
+
[string[]]$ProxyArgs
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
$root = Get-PersistentProfileRoot -Profile $Profile
|
| 409 |
+
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
| 410 |
+
|
| 411 |
+
$baseEnv = [ordered]@{
|
| 412 |
+
HEADROOM_PORT = "$Port"
|
| 413 |
+
HEADROOM_HOST = '127.0.0.1'
|
| 414 |
+
HEADROOM_MODE = $Mode
|
| 415 |
+
HEADROOM_BACKEND = $Backend
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
$manifest = [ordered]@{
|
| 419 |
+
profile = $Profile
|
| 420 |
+
preset = 'persistent-docker'
|
| 421 |
+
runtime_kind = 'docker'
|
| 422 |
+
supervisor_kind = 'none'
|
| 423 |
+
scope = 'user'
|
| 424 |
+
provider_mode = 'manual'
|
| 425 |
+
targets = @()
|
| 426 |
+
port = $Port
|
| 427 |
+
host = '127.0.0.1'
|
| 428 |
+
backend = $Backend
|
| 429 |
+
anyllm_provider = if ($AnyllmProvider) { $AnyllmProvider } else { $null }
|
| 430 |
+
region = if ($Region) { $Region } else { $null }
|
| 431 |
+
proxy_mode = $Mode
|
| 432 |
+
memory_enabled = $Memory
|
| 433 |
+
memory_db_path = "$ContainerHome/.headroom/memory.db"
|
| 434 |
+
telemetry_enabled = $TelemetryEnabled
|
| 435 |
+
image = $Image
|
| 436 |
+
service_name = "headroom-$Profile"
|
| 437 |
+
container_name = Get-PersistentContainerName -Profile $Profile
|
| 438 |
+
health_url = "http://127.0.0.1:$Port/readyz"
|
| 439 |
+
base_env = $baseEnv
|
| 440 |
+
tool_envs = @{}
|
| 441 |
+
proxy_args = $ProxyArgs
|
| 442 |
+
mutations = @()
|
| 443 |
+
artifacts = @()
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Get-PersistentManifestPath -Profile $Profile) -Encoding utf8
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
function Read-PersistentState {
|
| 450 |
+
param([string]$Profile)
|
| 451 |
+
|
| 452 |
+
Assert-ValidProfileName -Profile $Profile
|
| 453 |
+
$statePath = Get-PersistentStatePath -Profile $Profile
|
| 454 |
+
if (-not (Test-Path $statePath)) {
|
| 455 |
+
Fail "No docker-native persistent deployment profile named '$Profile'"
|
| 456 |
+
}
|
| 457 |
+
return Get-Content -Raw -Path $statePath | ConvertFrom-Json
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function Start-PersistentDockerInstall {
|
| 461 |
+
param(
|
| 462 |
+
[string]$Profile,
|
| 463 |
+
[string]$Image,
|
| 464 |
+
[int]$Port,
|
| 465 |
+
[string]$Backend,
|
| 466 |
+
[string]$AnyllmProvider,
|
| 467 |
+
[string]$Region,
|
| 468 |
+
[string]$Mode,
|
| 469 |
+
[bool]$Memory,
|
| 470 |
+
[bool]$TelemetryEnabled
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
Assert-ValidProfileName -Profile $Profile
|
| 474 |
+
$containerName = Get-PersistentContainerName -Profile $Profile
|
| 475 |
+
$proxyArgs = Get-ManifestProxyArgs -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
|
| 476 |
+
|
| 477 |
+
docker rm -f $containerName | Out-Null 2>$null
|
| 478 |
+
|
| 479 |
+
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 480 |
+
$dockerArgs.AddRange([string[]]@('run','-d','--restart','unless-stopped','--name',$containerName,'-p',"$Port`:$Port"))
|
| 481 |
+
$dockerArgs.AddRange((Get-PersistentDockerArgs))
|
| 482 |
+
$dockerArgs.Add($Image)
|
| 483 |
+
$dockerArgs.Add('--host')
|
| 484 |
+
$dockerArgs.Add('0.0.0.0')
|
| 485 |
+
for ($i = 2; $i -lt $proxyArgs.Count; $i++) {
|
| 486 |
+
$dockerArgs.Add($proxyArgs[$i])
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
& docker @dockerArgs | Out-Null
|
| 490 |
+
if ($LASTEXITCODE -ne 0) {
|
| 491 |
+
throw "Failed to start docker-native persistent deployment"
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
try {
|
| 495 |
+
Wait-Proxy -ContainerName $containerName -Port $Port
|
| 496 |
+
} catch {
|
| 497 |
+
docker rm -f $containerName | Out-Null 2>$null
|
| 498 |
+
throw
|
| 499 |
+
}
|
| 500 |
+
Write-PersistentState -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled
|
| 501 |
+
Write-PersistentManifest -Profile $Profile -Image $Image -Port $Port -Backend $Backend -AnyllmProvider $AnyllmProvider -Region $Region -Mode $Mode -Memory $Memory -TelemetryEnabled $TelemetryEnabled -ProxyArgs $proxyArgs
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function Stop-PersistentDockerInstall {
|
| 505 |
+
param([string]$Profile)
|
| 506 |
+
|
| 507 |
+
$state = Read-PersistentState -Profile $Profile
|
| 508 |
+
docker stop $state.container_name | Out-Null 2>$null
|
| 509 |
+
docker rm -f $state.container_name | Out-Null 2>$null
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
function Remove-PersistentDockerInstall {
|
| 513 |
+
param([string]$Profile)
|
| 514 |
+
|
| 515 |
+
$state = Read-PersistentState -Profile $Profile
|
| 516 |
+
docker stop $state.container_name | Out-Null 2>$null
|
| 517 |
+
docker rm -f $state.container_name | Out-Null 2>$null
|
| 518 |
+
$root = Get-PersistentProfileRoot -Profile $Profile
|
| 519 |
+
if (Test-Path $root) {
|
| 520 |
+
Remove-Item -Recurse -Force -Path $root
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
function Show-PersistentDockerInstallStatus {
|
| 525 |
+
param([string]$Profile)
|
| 526 |
+
|
| 527 |
+
$state = Read-PersistentState -Profile $Profile
|
| 528 |
+
$status = 'stopped'
|
| 529 |
+
$ready = 'no'
|
| 530 |
+
$running = docker ps --format '{{.Names}}'
|
| 531 |
+
if ($running -contains $state.container_name) {
|
| 532 |
+
$status = 'running'
|
| 533 |
+
try {
|
| 534 |
+
Invoke-WebRequest -UseBasicParsing -Uri $state.health_url | Out-Null
|
| 535 |
+
$ready = 'yes'
|
| 536 |
+
} catch {
|
| 537 |
+
$ready = 'no'
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
Write-Host "Profile: $($state.profile)"
|
| 542 |
+
Write-Host 'Preset: persistent-docker'
|
| 543 |
+
Write-Host 'Runtime: docker'
|
| 544 |
+
Write-Host 'Supervisor: none'
|
| 545 |
+
Write-Host "Port: $($state.port)"
|
| 546 |
+
Write-Host "Status: $status"
|
| 547 |
+
Write-Host "Ready: $ready"
|
| 548 |
+
Write-Host "Health URL: $($state.health_url)"
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
function Show-InstallHelp {
|
| 552 |
+
$lines = @(
|
| 553 |
+
'Usage: headroom install [OPTIONS] COMMAND [ARGS]...',
|
| 554 |
+
'',
|
| 555 |
+
' Manage persistent Docker-native Headroom deployments.',
|
| 556 |
+
'',
|
| 557 |
+
' The Docker-native wrapper currently supports the persistent-docker preset only.',
|
| 558 |
+
' Use the Python-native `headroom install` command for persistent-service and',
|
| 559 |
+
' persistent-task installs, or when you need provider/user/system config mutation.',
|
| 560 |
+
'',
|
| 561 |
+
'Options:',
|
| 562 |
+
' -?, --help Show this message and exit.',
|
| 563 |
+
'',
|
| 564 |
+
'Commands:',
|
| 565 |
+
' apply Install a persistent Docker deployment.',
|
| 566 |
+
' remove Remove a persistent Docker deployment.',
|
| 567 |
+
' restart Restart a persistent Docker deployment.',
|
| 568 |
+
' start Start a persistent Docker deployment.',
|
| 569 |
+
' status Show persistent Docker deployment status.',
|
| 570 |
+
' stop Stop a persistent Docker deployment.'
|
| 571 |
+
)
|
| 572 |
+
Write-Host ($lines -join [Environment]::NewLine)
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
function Show-InstallApplyHelp {
|
| 576 |
+
$lines = @(
|
| 577 |
+
'Usage: headroom install apply [OPTIONS]',
|
| 578 |
+
'',
|
| 579 |
+
' Install a persistent Docker deployment.',
|
| 580 |
+
'',
|
| 581 |
+
'Options:',
|
| 582 |
+
' --preset [persistent-docker] Docker-native wrapper supports persistent-docker only.',
|
| 583 |
+
' --runtime [docker] Docker-native wrapper supports runtime=docker only.',
|
| 584 |
+
' --profile TEXT Deployment profile name. [default: default]',
|
| 585 |
+
' -p, --port INTEGER Persistent proxy port. [default: 8787]',
|
| 586 |
+
' --backend TEXT Proxy backend. [default: anthropic]',
|
| 587 |
+
' --anyllm-provider TEXT Provider for any-llm backends.',
|
| 588 |
+
' --region TEXT Cloud region for Bedrock / Vertex style backends.',
|
| 589 |
+
' --mode TEXT Proxy optimization mode. [default: token]',
|
| 590 |
+
' --memory Enable persistent memory in the runtime.',
|
| 591 |
+
' --no-telemetry Disable anonymous telemetry in the runtime.',
|
| 592 |
+
' --image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]',
|
| 593 |
+
' -?, --help Show this message and exit.'
|
| 594 |
+
)
|
| 595 |
+
Write-Host ($lines -join [Environment]::NewLine)
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
function Parse-InstallApplyArgs {
|
| 599 |
+
param([string[]]$Arguments)
|
| 600 |
+
|
| 601 |
+
$profile = 'default'
|
| 602 |
+
$port = 8787
|
| 603 |
+
$backend = 'anthropic'
|
| 604 |
+
$anyllmProvider = $null
|
| 605 |
+
$region = $null
|
| 606 |
+
$mode = 'token'
|
| 607 |
+
$memory = $false
|
| 608 |
+
$telemetryEnabled = $true
|
| 609 |
+
$image = $HeadroomImage
|
| 610 |
+
|
| 611 |
+
$i = 0
|
| 612 |
+
while ($i -lt $Arguments.Count) {
|
| 613 |
+
$arg = $Arguments[$i]
|
| 614 |
+
switch -Regex ($arg) {
|
| 615 |
+
'^--preset$' {
|
| 616 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--preset'
|
| 617 |
+
if ($Arguments[$i + 1] -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
|
| 618 |
+
$i += 2
|
| 619 |
+
continue
|
| 620 |
+
}
|
| 621 |
+
'^--preset=' {
|
| 622 |
+
if (($arg -replace '^--preset=', '') -ne 'persistent-docker') { Fail 'Docker-native wrapper supports only --preset persistent-docker' }
|
| 623 |
+
$i += 1
|
| 624 |
+
continue
|
| 625 |
+
}
|
| 626 |
+
'^--runtime$' {
|
| 627 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--runtime'
|
| 628 |
+
if ($Arguments[$i + 1] -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
|
| 629 |
+
$i += 2
|
| 630 |
+
continue
|
| 631 |
+
}
|
| 632 |
+
'^--runtime=' {
|
| 633 |
+
if (($arg -replace '^--runtime=', '') -ne 'docker') { Fail 'Docker-native wrapper supports only --runtime docker' }
|
| 634 |
+
$i += 1
|
| 635 |
+
continue
|
| 636 |
+
}
|
| 637 |
+
'^(--scope|--providers|--target)$' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
|
| 638 |
+
'^(--scope=|--providers=|--target=)' { Fail 'Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows' }
|
| 639 |
+
'^--profile$' {
|
| 640 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
|
| 641 |
+
$profile = $Arguments[$i + 1]
|
| 642 |
+
$i += 2
|
| 643 |
+
continue
|
| 644 |
+
}
|
| 645 |
+
'^--profile=' {
|
| 646 |
+
$profile = $arg -replace '^--profile=', ''
|
| 647 |
+
$i += 1
|
| 648 |
+
continue
|
| 649 |
+
}
|
| 650 |
+
'^(--port|-p)$' {
|
| 651 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 652 |
+
$port = Parse-PortValue -Value $Arguments[$i + 1]
|
| 653 |
+
$i += 2
|
| 654 |
+
continue
|
| 655 |
+
}
|
| 656 |
+
'^(--port=|-p=)' {
|
| 657 |
+
$port = Parse-PortValue -Value ($arg -replace '^(--port=|-p=)', '')
|
| 658 |
+
$i += 1
|
| 659 |
+
continue
|
| 660 |
+
}
|
| 661 |
+
'^--backend$' {
|
| 662 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--backend'
|
| 663 |
+
$backend = $Arguments[$i + 1]
|
| 664 |
+
$i += 2
|
| 665 |
+
continue
|
| 666 |
+
}
|
| 667 |
+
'^--backend=' {
|
| 668 |
+
$backend = $arg -replace '^--backend=', ''
|
| 669 |
+
$i += 1
|
| 670 |
+
continue
|
| 671 |
+
}
|
| 672 |
+
'^--anyllm-provider$' {
|
| 673 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--anyllm-provider'
|
| 674 |
+
$anyllmProvider = $Arguments[$i + 1]
|
| 675 |
+
$i += 2
|
| 676 |
+
continue
|
| 677 |
+
}
|
| 678 |
+
'^--anyllm-provider=' {
|
| 679 |
+
$anyllmProvider = $arg -replace '^--anyllm-provider=', ''
|
| 680 |
+
$i += 1
|
| 681 |
+
continue
|
| 682 |
+
}
|
| 683 |
+
'^--region$' {
|
| 684 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--region'
|
| 685 |
+
$region = $Arguments[$i + 1]
|
| 686 |
+
$i += 2
|
| 687 |
+
continue
|
| 688 |
+
}
|
| 689 |
+
'^--region=' {
|
| 690 |
+
$region = $arg -replace '^--region=', ''
|
| 691 |
+
$i += 1
|
| 692 |
+
continue
|
| 693 |
+
}
|
| 694 |
+
'^--mode$' {
|
| 695 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--mode'
|
| 696 |
+
$mode = $Arguments[$i + 1]
|
| 697 |
+
$i += 2
|
| 698 |
+
continue
|
| 699 |
+
}
|
| 700 |
+
'^--mode=' {
|
| 701 |
+
$mode = $arg -replace '^--mode=', ''
|
| 702 |
+
$i += 1
|
| 703 |
+
continue
|
| 704 |
+
}
|
| 705 |
+
'^--memory$' {
|
| 706 |
+
$memory = $true
|
| 707 |
+
$i += 1
|
| 708 |
+
continue
|
| 709 |
+
}
|
| 710 |
+
'^--no-telemetry$' {
|
| 711 |
+
$telemetryEnabled = $false
|
| 712 |
+
$i += 1
|
| 713 |
+
continue
|
| 714 |
+
}
|
| 715 |
+
'^--image$' {
|
| 716 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--image'
|
| 717 |
+
$image = $Arguments[$i + 1]
|
| 718 |
+
$i += 2
|
| 719 |
+
continue
|
| 720 |
+
}
|
| 721 |
+
'^--image=' {
|
| 722 |
+
$image = $arg -replace '^--image=', ''
|
| 723 |
+
$i += 1
|
| 724 |
+
continue
|
| 725 |
+
}
|
| 726 |
+
'^(--help|-\\?)$' {
|
| 727 |
+
Show-InstallApplyHelp
|
| 728 |
+
exit 0
|
| 729 |
+
}
|
| 730 |
+
default {
|
| 731 |
+
Fail "Unsupported option for 'headroom install apply': $arg"
|
| 732 |
+
}
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
return [pscustomobject]@{
|
| 737 |
+
Profile = $profile
|
| 738 |
+
Port = $port
|
| 739 |
+
Backend = $backend
|
| 740 |
+
AnyllmProvider = $anyllmProvider
|
| 741 |
+
Region = $region
|
| 742 |
+
Mode = $mode
|
| 743 |
+
Memory = $memory
|
| 744 |
+
TelemetryEnabled = $telemetryEnabled
|
| 745 |
+
Image = $image
|
| 746 |
+
}
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
function Parse-InstallProfileArgs {
|
| 750 |
+
param([string[]]$Arguments)
|
| 751 |
+
|
| 752 |
+
$profile = 'default'
|
| 753 |
+
$i = 0
|
| 754 |
+
while ($i -lt $Arguments.Count) {
|
| 755 |
+
$arg = $Arguments[$i]
|
| 756 |
+
switch -Regex ($arg) {
|
| 757 |
+
'^--profile$' {
|
| 758 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option '--profile'
|
| 759 |
+
$profile = $Arguments[$i + 1]
|
| 760 |
+
$i += 2
|
| 761 |
+
continue
|
| 762 |
+
}
|
| 763 |
+
'^--profile=' {
|
| 764 |
+
$profile = $arg -replace '^--profile=', ''
|
| 765 |
+
$i += 1
|
| 766 |
+
continue
|
| 767 |
+
}
|
| 768 |
+
'^(--help|-\\?)$' {
|
| 769 |
+
Show-InstallHelp
|
| 770 |
+
exit 0
|
| 771 |
+
}
|
| 772 |
+
default {
|
| 773 |
+
Fail "Unsupported option for 'headroom install': $arg"
|
| 774 |
+
}
|
| 775 |
+
}
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
return $profile
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
function Invoke-ClaudeRtkInit {
|
| 782 |
$rtkPath = Join-Path $HostHome '.headroom\bin\rtk.exe'
|
| 783 |
if (-not (Test-Path $rtkPath)) {
|
|
|
|
| 850 |
$arg = $Arguments[$i]
|
| 851 |
switch -Regex ($arg) {
|
| 852 |
'^--plugin-path$' {
|
| 853 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 854 |
$pluginPath = $Arguments[$i + 1]
|
| 855 |
$i += 2
|
| 856 |
continue
|
|
|
|
| 861 |
continue
|
| 862 |
}
|
| 863 |
'^--plugin-spec$' {
|
| 864 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 865 |
$pluginSpec = $Arguments[$i + 1]
|
| 866 |
$i += 2
|
| 867 |
continue
|
|
|
|
| 882 |
continue
|
| 883 |
}
|
| 884 |
'^--proxy-port$' {
|
| 885 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 886 |
+
$proxyPort = Parse-PortValue -Value $Arguments[$i + 1]
|
| 887 |
$i += 2
|
| 888 |
continue
|
| 889 |
}
|
| 890 |
'^--proxy-port=' {
|
| 891 |
+
$proxyPort = Parse-PortValue -Value ($arg -replace '^--proxy-port=', '')
|
| 892 |
$i += 1
|
| 893 |
continue
|
| 894 |
}
|
| 895 |
'^--startup-timeout-ms$' {
|
| 896 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 897 |
+
$startupTimeoutMs = Parse-PositiveIntegerValue -Value $Arguments[$i + 1]
|
| 898 |
$i += 2
|
| 899 |
continue
|
| 900 |
}
|
| 901 |
'^--startup-timeout-ms=' {
|
| 902 |
+
$startupTimeoutMs = Parse-PositiveIntegerValue -Value ($arg -replace '^--startup-timeout-ms=', '')
|
| 903 |
$i += 1
|
| 904 |
continue
|
| 905 |
}
|
| 906 |
'^--gateway-provider-id$' {
|
| 907 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 908 |
$gatewayProviderIds.Add($Arguments[$i + 1])
|
| 909 |
$i += 2
|
| 910 |
continue
|
|
|
|
| 915 |
continue
|
| 916 |
}
|
| 917 |
'^--python-path$' {
|
| 918 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 919 |
$pythonPath = $Arguments[$i + 1]
|
| 920 |
$i += 2
|
| 921 |
continue
|
|
|
|
| 1318 |
param([string[]]$Arguments)
|
| 1319 |
|
| 1320 |
$known = New-Object System.Collections.Generic.List[string]
|
| 1321 |
+
$hostArgs = New-Object System.Collections.Generic.List[string]
|
| 1322 |
$port = 8787
|
| 1323 |
$noRtk = $false
|
| 1324 |
$noProxy = $false
|
|
|
|
| 1333 |
switch -Regex ($arg) {
|
| 1334 |
'^--$' {
|
| 1335 |
for ($j = $i + 1; $j -lt $Arguments.Count; $j++) {
|
| 1336 |
+
$hostArgs.Add($Arguments[$j])
|
| 1337 |
}
|
| 1338 |
$i = $Arguments.Count
|
| 1339 |
continue
|
| 1340 |
}
|
| 1341 |
'^--port$|^-p$' {
|
| 1342 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 1343 |
+
$port = Parse-PortValue -Value $Arguments[$i + 1]
|
| 1344 |
$known.Add($arg)
|
| 1345 |
$known.Add($Arguments[$i + 1])
|
| 1346 |
$i += 2
|
| 1347 |
continue
|
| 1348 |
}
|
| 1349 |
'^--port=' {
|
| 1350 |
+
$port = Parse-PortValue -Value ($arg -replace '^--port=', '')
|
| 1351 |
$known.Add($arg)
|
| 1352 |
$i += 1
|
| 1353 |
continue
|
|
|
|
| 1376 |
continue
|
| 1377 |
}
|
| 1378 |
'^--backend$' {
|
| 1379 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 1380 |
$backend = $Arguments[$i + 1]
|
| 1381 |
$known.Add($arg)
|
| 1382 |
$known.Add($Arguments[$i + 1])
|
|
|
|
| 1390 |
continue
|
| 1391 |
}
|
| 1392 |
'^--anyllm-provider$' {
|
| 1393 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 1394 |
$anyllm = $Arguments[$i + 1]
|
| 1395 |
$known.Add($arg)
|
| 1396 |
$known.Add($Arguments[$i + 1])
|
|
|
|
| 1404 |
continue
|
| 1405 |
}
|
| 1406 |
'^--region$' {
|
| 1407 |
+
Require-OptionValue -Arguments $Arguments -Index $i -Option $arg
|
| 1408 |
$region = $Arguments[$i + 1]
|
| 1409 |
$known.Add($arg)
|
| 1410 |
$known.Add($Arguments[$i + 1])
|
|
|
|
| 1419 |
}
|
| 1420 |
default {
|
| 1421 |
for ($j = $i; $j -lt $Arguments.Count; $j++) {
|
| 1422 |
+
$hostArgs.Add($Arguments[$j])
|
| 1423 |
}
|
| 1424 |
$i = $Arguments.Count
|
| 1425 |
}
|
|
|
|
| 1428 |
|
| 1429 |
[pscustomobject]@{
|
| 1430 |
KnownArgs = $known.ToArray()
|
| 1431 |
+
HostArgs = $hostArgs.ToArray()
|
| 1432 |
Port = $port
|
| 1433 |
NoRtk = $noRtk
|
| 1434 |
NoProxy = $noProxy
|
|
|
|
| 1446 |
)
|
| 1447 |
|
| 1448 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 1449 |
+
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
| 1450 |
+
Add-TtyArgs -ArgsList $dockerArgs
|
| 1451 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 1452 |
$dockerArgs.Add('--env')
|
| 1453 |
$dockerArgs.Add("HEADROOM_RTK_TARGET=$(Get-RtkTarget)")
|
|
|
|
| 1473 |
}
|
| 1474 |
|
| 1475 |
switch ($args[0]) {
|
| 1476 |
+
'install' {
|
| 1477 |
+
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
|
| 1478 |
+
Show-InstallHelp
|
| 1479 |
+
exit 0
|
| 1480 |
+
}
|
| 1481 |
+
|
| 1482 |
+
$installCommand = $args[1]
|
| 1483 |
+
$installArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
|
| 1484 |
+
|
| 1485 |
+
switch ($installCommand) {
|
| 1486 |
+
'apply' {
|
| 1487 |
+
$parsed = Parse-InstallApplyArgs -Arguments $installArgs
|
| 1488 |
+
Start-PersistentDockerInstall -Profile $parsed.Profile -Image $parsed.Image -Port $parsed.Port -Backend $parsed.Backend -AnyllmProvider $parsed.AnyllmProvider -Region $parsed.Region -Mode $parsed.Mode -Memory $parsed.Memory -TelemetryEnabled $parsed.TelemetryEnabled
|
| 1489 |
+
Write-Host "Installed docker-native persistent deployment '$($parsed.Profile)' on port $($parsed.Port)."
|
| 1490 |
+
exit 0
|
| 1491 |
+
}
|
| 1492 |
+
'status' {
|
| 1493 |
+
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
| 1494 |
+
Show-PersistentDockerInstallStatus -Profile $profile
|
| 1495 |
+
exit 0
|
| 1496 |
+
}
|
| 1497 |
+
'start' {
|
| 1498 |
+
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
| 1499 |
+
$state = Read-PersistentState -Profile $profile
|
| 1500 |
+
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
|
| 1501 |
+
Write-Host "Started docker-native persistent deployment '$profile'."
|
| 1502 |
+
exit 0
|
| 1503 |
+
}
|
| 1504 |
+
'stop' {
|
| 1505 |
+
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
| 1506 |
+
Stop-PersistentDockerInstall -Profile $profile
|
| 1507 |
+
Write-Host "Stopped docker-native persistent deployment '$profile'."
|
| 1508 |
+
exit 0
|
| 1509 |
+
}
|
| 1510 |
+
'restart' {
|
| 1511 |
+
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
| 1512 |
+
$state = Read-PersistentState -Profile $profile
|
| 1513 |
+
Start-PersistentDockerInstall -Profile $state.profile -Image $state.image -Port $state.port -Backend $state.backend -AnyllmProvider $state.anyllm_provider -Region $state.region -Mode $state.proxy_mode -Memory ([bool]$state.memory_enabled) -TelemetryEnabled ([bool]$state.telemetry_enabled)
|
| 1514 |
+
Write-Host "Restarted docker-native persistent deployment '$profile'."
|
| 1515 |
+
exit 0
|
| 1516 |
+
}
|
| 1517 |
+
'remove' {
|
| 1518 |
+
$profile = Parse-InstallProfileArgs -Arguments $installArgs
|
| 1519 |
+
Remove-PersistentDockerInstall -Profile $profile
|
| 1520 |
+
Write-Host "Removed docker-native persistent deployment '$profile'."
|
| 1521 |
+
exit 0
|
| 1522 |
+
}
|
| 1523 |
+
default {
|
| 1524 |
+
Fail "Unsupported install target: $installCommand"
|
| 1525 |
+
}
|
| 1526 |
+
}
|
| 1527 |
+
}
|
| 1528 |
'wrap' {
|
| 1529 |
if ($args.Count -eq 1 -or $args[1] -eq '--help' -or $args[1] -eq '-?') {
|
| 1530 |
Invoke-HeadroomDocker -Arguments @('wrap','--help')
|
|
|
|
| 1641 |
foreach ($arg in $args) { $forwardArgs.Add($arg) }
|
| 1642 |
for ($i = 1; $i -lt $args.Count; $i++) {
|
| 1643 |
if ($args[$i] -eq '--port' -or $args[$i] -eq '-p') {
|
| 1644 |
+
Require-OptionValue -Arguments $args -Index $i -Option $args[$i]
|
| 1645 |
+
$port = Parse-PortValue -Value $args[$i + 1]
|
| 1646 |
break
|
| 1647 |
}
|
| 1648 |
if ($args[$i] -match '^--port=') {
|
| 1649 |
+
$port = Parse-PortValue -Value ($args[$i] -replace '^--port=', '')
|
| 1650 |
break
|
| 1651 |
}
|
| 1652 |
}
|
| 1653 |
|
| 1654 |
$dockerArgs = New-Object System.Collections.Generic.List[string]
|
| 1655 |
+
$dockerArgs.AddRange([string[]]@('run','--rm'))
|
| 1656 |
+
Add-TtyArgs -ArgsList $dockerArgs
|
| 1657 |
+
$dockerArgs.AddRange([string[]]@('-p',"$port`:$port"))
|
| 1658 |
$dockerArgs.AddRange((Get-SharedDockerArgs))
|
| 1659 |
$dockerArgs.Add('--entrypoint')
|
| 1660 |
$dockerArgs.Add('headroom')
|
|
|
|
| 1672 |
}
|
| 1673 |
'@
|
| 1674 |
|
| 1675 |
+
$cmdWrapper = ([string][char]64) + "echo off`r`npowershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ""%~dp0headroom.ps1"" %*`r`n"
|
|
|
|
|
|
|
|
|
|
| 1676 |
|
| 1677 |
+
Set-Content -Path $wrapperPath -Value $wrapper -Encoding utf8
|
| 1678 |
+
Set-Content -Path $cmdPath -Value $cmdWrapper -Encoding ascii
|
| 1679 |
}
|
| 1680 |
|
| 1681 |
Require-Command docker
|
| 1682 |
docker version | Out-Null
|
| 1683 |
+
if ($LASTEXITCODE -ne 0) {
|
| 1684 |
+
throw 'Docker is installed but not available to the current user'
|
| 1685 |
+
}
|
| 1686 |
|
| 1687 |
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
| 1688 |
Write-Wrapper -TargetDir $InstallDir
|
| 1689 |
Ensure-PathEntry -PathEntry $InstallDir
|
| 1690 |
Ensure-ProfileBlock -PathEntry $InstallDir
|
| 1691 |
|
| 1692 |
+
if ($env:HEADROOM_DOCKER_IMAGE) {
|
| 1693 |
+
$null = docker image inspect $InstallImage 2>$null
|
| 1694 |
+
if ($LASTEXITCODE -eq 0) {
|
| 1695 |
+
Write-Info "Using existing HEADROOM_DOCKER_IMAGE=$InstallImage"
|
| 1696 |
+
} else {
|
| 1697 |
+
Write-Info "Pulling $InstallImage"
|
| 1698 |
+
docker pull $InstallImage | Out-Null
|
| 1699 |
+
}
|
| 1700 |
+
} else {
|
| 1701 |
+
Write-Info "Pulling $ImageDefault"
|
| 1702 |
+
docker pull $ImageDefault | Out-Null
|
| 1703 |
+
}
|
| 1704 |
|
| 1705 |
+
Write-Host ""
|
| 1706 |
+
Write-Host "Headroom Docker-native install complete."
|
| 1707 |
+
Write-Host ""
|
| 1708 |
Write-Host "Installed wrappers:"
|
| 1709 |
Write-Host " $InstallDir\headroom.ps1"
|
| 1710 |
Write-Host " $InstallDir\headroom.cmd"
|
| 1711 |
+
Write-Host ""
|
| 1712 |
+
Write-Host "Next steps:"
|
| 1713 |
Write-Host " 1. Restart PowerShell"
|
| 1714 |
Write-Host " 2. Try: headroom proxy"
|
| 1715 |
Write-Host " 3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md"
|
scripts/install.sh
CHANGED
|
@@ -3,11 +3,18 @@
|
|
| 3 |
set -euo pipefail
|
| 4 |
|
| 5 |
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
|
|
|
|
| 6 |
INSTALL_DIR="${HOME}/.local/bin"
|
| 7 |
if [[ ! -d "${HOME}/.local" ]]; then
|
| 8 |
INSTALL_DIR="${HOME}/bin"
|
| 9 |
fi
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
info() {
|
| 12 |
printf '==> %s\n' "$*"
|
| 13 |
}
|
|
@@ -46,8 +53,9 @@ ${marker_end}"
|
|
| 46 |
write_wrapper() {
|
| 47 |
local wrapper_path="${INSTALL_DIR}/headroom"
|
| 48 |
|
| 49 |
-
|
| 50 |
-
#!
|
|
|
|
| 51 |
|
| 52 |
set -euo pipefail
|
| 53 |
|
|
@@ -55,6 +63,11 @@ HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
|
|
| 55 |
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
|
| 56 |
HEADROOM_HOST_HOME="${HOME:?}"
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
warn() {
|
| 59 |
printf 'WARN: %s\n' "$*" >&2
|
| 60 |
}
|
|
@@ -168,7 +181,11 @@ wait_for_proxy() {
|
|
| 168 |
local attempt
|
| 169 |
|
| 170 |
for attempt in $(seq 1 45); do
|
| 171 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
return 0
|
| 173 |
fi
|
| 174 |
|
|
@@ -209,6 +226,531 @@ stop_proxy_container() {
|
|
| 209 |
fi
|
| 210 |
}
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
run_claude_rtk_init() {
|
| 213 |
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
|
| 214 |
if [[ ! -x "${rtk_bin}" ]]; then
|
|
@@ -251,12 +793,15 @@ parse_wrap_args() {
|
|
| 251 |
break
|
| 252 |
;;
|
| 253 |
--port|-p)
|
|
|
|
| 254 |
out_port="$2"
|
|
|
|
| 255 |
out_known+=("$1" "$2")
|
| 256 |
shift 2
|
| 257 |
;;
|
| 258 |
--port=*)
|
| 259 |
out_port="${1#*=}"
|
|
|
|
| 260 |
out_known+=("$1")
|
| 261 |
shift
|
| 262 |
;;
|
|
@@ -280,6 +825,7 @@ parse_wrap_args() {
|
|
| 280 |
shift
|
| 281 |
;;
|
| 282 |
--backend)
|
|
|
|
| 283 |
out_backend="$2"
|
| 284 |
out_known+=("$1" "$2")
|
| 285 |
shift 2
|
|
@@ -290,6 +836,7 @@ parse_wrap_args() {
|
|
| 290 |
shift
|
| 291 |
;;
|
| 292 |
--anyllm-provider)
|
|
|
|
| 293 |
out_anyllm="$2"
|
| 294 |
out_known+=("$1" "$2")
|
| 295 |
shift 2
|
|
@@ -300,6 +847,7 @@ parse_wrap_args() {
|
|
| 300 |
shift
|
| 301 |
;;
|
| 302 |
--region)
|
|
|
|
| 303 |
out_region="$2"
|
| 304 |
out_known+=("$1" "$2")
|
| 305 |
shift 2
|
|
@@ -381,6 +929,7 @@ parse_openclaw_wrap_args() {
|
|
| 381 |
while (($#)); do
|
| 382 |
case "$1" in
|
| 383 |
--plugin-path)
|
|
|
|
| 384 |
out_plugin_path="$2"
|
| 385 |
shift 2
|
| 386 |
;;
|
|
@@ -389,6 +938,7 @@ parse_openclaw_wrap_args() {
|
|
| 389 |
shift
|
| 390 |
;;
|
| 391 |
--plugin-spec)
|
|
|
|
| 392 |
out_plugin_spec="$2"
|
| 393 |
shift 2
|
| 394 |
;;
|
|
@@ -405,22 +955,29 @@ parse_openclaw_wrap_args() {
|
|
| 405 |
shift
|
| 406 |
;;
|
| 407 |
--proxy-port)
|
|
|
|
| 408 |
out_proxy_port="$2"
|
|
|
|
| 409 |
shift 2
|
| 410 |
;;
|
| 411 |
--proxy-port=*)
|
| 412 |
out_proxy_port="${1#*=}"
|
|
|
|
| 413 |
shift
|
| 414 |
;;
|
| 415 |
--startup-timeout-ms)
|
|
|
|
| 416 |
out_startup_timeout_ms="$2"
|
|
|
|
| 417 |
shift 2
|
| 418 |
;;
|
| 419 |
--startup-timeout-ms=*)
|
| 420 |
out_startup_timeout_ms="${1#*=}"
|
|
|
|
| 421 |
shift
|
| 422 |
;;
|
| 423 |
--gateway-provider-id)
|
|
|
|
| 424 |
out_gateway_provider_ids+=("$2")
|
| 425 |
shift 2
|
| 426 |
;;
|
|
@@ -429,6 +986,7 @@ parse_openclaw_wrap_args() {
|
|
| 429 |
shift
|
| 430 |
;;
|
| 431 |
--python-path)
|
|
|
|
| 432 |
out_python_path="$2"
|
| 433 |
shift 2
|
| 434 |
;;
|
|
@@ -795,6 +1353,57 @@ main() {
|
|
| 795 |
fi
|
| 796 |
|
| 797 |
case "$1" in
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
wrap)
|
| 799 |
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
|
| 800 |
run_headroom wrap --help
|
|
@@ -911,12 +1520,15 @@ EOF
|
|
| 911 |
while (($#)); do
|
| 912 |
case "$1" in
|
| 913 |
--port|-p)
|
|
|
|
| 914 |
port="$2"
|
|
|
|
| 915 |
args+=("$1" "$2")
|
| 916 |
shift 2
|
| 917 |
;;
|
| 918 |
--port=*)
|
| 919 |
port="${1#*=}"
|
|
|
|
| 920 |
args+=("$1")
|
| 921 |
shift
|
| 922 |
;;
|
|
@@ -942,6 +1554,7 @@ EOF
|
|
| 942 |
|
| 943 |
main "$@"
|
| 944 |
WRAPPER
|
|
|
|
| 945 |
|
| 946 |
chmod +x "${wrapper_path}"
|
| 947 |
}
|
|
@@ -957,8 +1570,17 @@ main() {
|
|
| 957 |
append_path_block "${HOME}/.zshrc"
|
| 958 |
append_path_block "${HOME}/.profile"
|
| 959 |
|
| 960 |
-
|
| 961 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
|
| 963 |
cat <<EOF
|
| 964 |
|
|
|
|
| 3 |
set -euo pipefail
|
| 4 |
|
| 5 |
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
|
| 6 |
+
INSTALL_IMAGE="${HEADROOM_DOCKER_IMAGE:-${IMAGE_DEFAULT}}"
|
| 7 |
INSTALL_DIR="${HOME}/.local/bin"
|
| 8 |
if [[ ! -d "${HOME}/.local" ]]; then
|
| 9 |
INSTALL_DIR="${HOME}/bin"
|
| 10 |
fi
|
| 11 |
|
| 12 |
+
BASH_PATH="${BASH:-$(command -v bash)}"
|
| 13 |
+
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
|
| 14 |
+
printf 'ERROR: Headroom Docker-native install requires bash >= 4.3\n' >&2
|
| 15 |
+
exit 1
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
info() {
|
| 19 |
printf '==> %s\n' "$*"
|
| 20 |
}
|
|
|
|
| 53 |
write_wrapper() {
|
| 54 |
local wrapper_path="${INSTALL_DIR}/headroom"
|
| 55 |
|
| 56 |
+
{
|
| 57 |
+
printf '#!%s\n\n' "${BASH_PATH}"
|
| 58 |
+
cat <<'WRAPPER'
|
| 59 |
|
| 60 |
set -euo pipefail
|
| 61 |
|
|
|
|
| 63 |
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
|
| 64 |
HEADROOM_HOST_HOME="${HOME:?}"
|
| 65 |
|
| 66 |
+
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
|
| 67 |
+
printf 'ERROR: Headroom Docker-native wrapper requires bash >= 4.3\n' >&2
|
| 68 |
+
exit 1
|
| 69 |
+
fi
|
| 70 |
+
|
| 71 |
warn() {
|
| 72 |
printf 'WARN: %s\n' "$*" >&2
|
| 73 |
}
|
|
|
|
| 181 |
local attempt
|
| 182 |
|
| 183 |
for attempt in $(seq 1 45); do
|
| 184 |
+
if command -v curl >/dev/null 2>&1; then
|
| 185 |
+
if curl --fail --silent "http://127.0.0.1:${port}/readyz" >/dev/null; then
|
| 186 |
+
return 0
|
| 187 |
+
fi
|
| 188 |
+
elif (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
|
| 189 |
return 0
|
| 190 |
fi
|
| 191 |
|
|
|
|
| 226 |
fi
|
| 227 |
}
|
| 228 |
|
| 229 |
+
persistent_profile_root() {
|
| 230 |
+
local profile="$1"
|
| 231 |
+
validate_profile_name "${profile}"
|
| 232 |
+
printf '%s/.headroom/deploy/%s\n' "${HEADROOM_HOST_HOME}" "${profile}"
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
persistent_state_path() {
|
| 236 |
+
local profile="$1"
|
| 237 |
+
printf '%s/docker-native.env\n' "$(persistent_profile_root "${profile}")"
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
persistent_manifest_path() {
|
| 241 |
+
local profile="$1"
|
| 242 |
+
printf '%s/manifest.json\n' "$(persistent_profile_root "${profile}")"
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
persistent_container_name() {
|
| 246 |
+
local profile="$1"
|
| 247 |
+
validate_profile_name "${profile}"
|
| 248 |
+
printf 'headroom-%s\n' "${profile}"
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
validate_profile_name() {
|
| 252 |
+
local profile="$1"
|
| 253 |
+
[[ "${profile}" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid profile name '${profile}'"
|
| 254 |
+
[[ "${profile}" != "." && "${profile}" != ".." ]] || die "Invalid profile name '${profile}'"
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
validate_port() {
|
| 258 |
+
local port="$1"
|
| 259 |
+
[[ "${port}" =~ ^[0-9]+$ ]] || die "Invalid port '${port}'"
|
| 260 |
+
((10#${port} >= 1 && 10#${port} <= 65535)) || die "Invalid port '${port}'"
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
validate_positive_integer() {
|
| 264 |
+
local value="$1"
|
| 265 |
+
[[ "${value}" =~ ^[0-9]+$ ]] || die "Invalid value '${value}'"
|
| 266 |
+
((10#${value} >= 1)) || die "Invalid value '${value}'"
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
require_option_value() {
|
| 270 |
+
(($# >= 2)) || die "Option $1 requires a value"
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
json_escape() {
|
| 274 |
+
local value="$1"
|
| 275 |
+
value="${value//\\/\\\\}"
|
| 276 |
+
value="${value//\"/\\\"}"
|
| 277 |
+
value="${value//$'\n'/\\n}"
|
| 278 |
+
printf '%s' "${value}"
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
json_array_from_args() {
|
| 282 |
+
local first=1
|
| 283 |
+
local arg
|
| 284 |
+
printf '['
|
| 285 |
+
for arg in "$@"; do
|
| 286 |
+
if [[ "${first}" -eq 0 ]]; then
|
| 287 |
+
printf ','
|
| 288 |
+
fi
|
| 289 |
+
first=0
|
| 290 |
+
printf '"%s"' "$(json_escape "${arg}")"
|
| 291 |
+
done
|
| 292 |
+
printf ']'
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
append_persistent_container_args() {
|
| 296 |
+
local -n ref=$1
|
| 297 |
+
|
| 298 |
+
ensure_host_dirs
|
| 299 |
+
ref+=(--workdir "${HEADROOM_CONTAINER_HOME}")
|
| 300 |
+
ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}")
|
| 301 |
+
ref+=(--env "PYTHONUNBUFFERED=1")
|
| 302 |
+
ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom")
|
| 303 |
+
ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude")
|
| 304 |
+
ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex")
|
| 305 |
+
ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini")
|
| 306 |
+
|
| 307 |
+
if command -v id >/dev/null 2>&1; then
|
| 308 |
+
ref+=(--user "$(id -u):$(id -g)")
|
| 309 |
+
fi
|
| 310 |
+
|
| 311 |
+
append_passthrough_envs "$1"
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
build_manifest_proxy_args() {
|
| 315 |
+
local -n out_args=$1
|
| 316 |
+
local port="$2"
|
| 317 |
+
local proxy_mode="$3"
|
| 318 |
+
local backend="$4"
|
| 319 |
+
local anyllm="$5"
|
| 320 |
+
local region="$6"
|
| 321 |
+
local memory_enabled="$7"
|
| 322 |
+
local telemetry_enabled="$8"
|
| 323 |
+
|
| 324 |
+
out_args=(--host 127.0.0.1 --port "${port}" --mode "${proxy_mode}" --backend "${backend}")
|
| 325 |
+
if [[ "${telemetry_enabled}" -eq 0 ]]; then
|
| 326 |
+
out_args+=(--no-telemetry)
|
| 327 |
+
fi
|
| 328 |
+
if [[ "${memory_enabled}" -eq 1 ]]; then
|
| 329 |
+
out_args+=(--memory --memory-db-path "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")
|
| 330 |
+
fi
|
| 331 |
+
if [[ -n "${anyllm}" ]]; then
|
| 332 |
+
out_args+=(--anyllm-provider "${anyllm}")
|
| 333 |
+
fi
|
| 334 |
+
if [[ -n "${region}" ]]; then
|
| 335 |
+
out_args+=(--region "${region}")
|
| 336 |
+
fi
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
write_persistent_state() {
|
| 340 |
+
local profile="$1"
|
| 341 |
+
local image="$2"
|
| 342 |
+
local port="$3"
|
| 343 |
+
local backend="$4"
|
| 344 |
+
local anyllm="$5"
|
| 345 |
+
local region="$6"
|
| 346 |
+
local proxy_mode="$7"
|
| 347 |
+
local memory_enabled="$8"
|
| 348 |
+
local telemetry_enabled="$9"
|
| 349 |
+
|
| 350 |
+
local root
|
| 351 |
+
root="$(persistent_profile_root "${profile}")"
|
| 352 |
+
mkdir -p "${root}"
|
| 353 |
+
|
| 354 |
+
{
|
| 355 |
+
printf 'PROFILE=%s\n' "${profile}"
|
| 356 |
+
printf 'IMAGE=%s\n' "${image}"
|
| 357 |
+
printf 'PORT=%s\n' "${port}"
|
| 358 |
+
printf 'BACKEND=%s\n' "${backend}"
|
| 359 |
+
printf 'ANYLLM_PROVIDER=%s\n' "${anyllm}"
|
| 360 |
+
printf 'REGION=%s\n' "${region}"
|
| 361 |
+
printf 'PROXY_MODE=%s\n' "${proxy_mode}"
|
| 362 |
+
printf 'MEMORY_ENABLED=%s\n' "${memory_enabled}"
|
| 363 |
+
printf 'TELEMETRY_ENABLED=%s\n' "${telemetry_enabled}"
|
| 364 |
+
printf 'CONTAINER_NAME=%s\n' "$(persistent_container_name "${profile}")"
|
| 365 |
+
printf 'HEALTH_URL=%s\n' "http://127.0.0.1:${port}/readyz"
|
| 366 |
+
} >"$(persistent_state_path "${profile}")"
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
write_persistent_manifest() {
|
| 370 |
+
local profile="$1"
|
| 371 |
+
local image="$2"
|
| 372 |
+
local port="$3"
|
| 373 |
+
local backend="$4"
|
| 374 |
+
local anyllm="$5"
|
| 375 |
+
local region="$6"
|
| 376 |
+
local proxy_mode="$7"
|
| 377 |
+
local memory_enabled="$8"
|
| 378 |
+
local telemetry_enabled="$9"
|
| 379 |
+
local -n proxy_args_ref=${10}
|
| 380 |
+
|
| 381 |
+
local root
|
| 382 |
+
local manifest_path
|
| 383 |
+
local anyllm_json="null"
|
| 384 |
+
local region_json="null"
|
| 385 |
+
local memory_json="false"
|
| 386 |
+
local telemetry_json="true"
|
| 387 |
+
|
| 388 |
+
root="$(persistent_profile_root "${profile}")"
|
| 389 |
+
manifest_path="$(persistent_manifest_path "${profile}")"
|
| 390 |
+
mkdir -p "${root}"
|
| 391 |
+
|
| 392 |
+
if [[ -n "${anyllm}" ]]; then
|
| 393 |
+
anyllm_json="\"$(json_escape "${anyllm}")\""
|
| 394 |
+
fi
|
| 395 |
+
if [[ -n "${region}" ]]; then
|
| 396 |
+
region_json="\"$(json_escape "${region}")\""
|
| 397 |
+
fi
|
| 398 |
+
if [[ "${memory_enabled}" -eq 1 ]]; then
|
| 399 |
+
memory_json="true"
|
| 400 |
+
fi
|
| 401 |
+
if [[ "${telemetry_enabled}" -eq 0 ]]; then
|
| 402 |
+
telemetry_json="false"
|
| 403 |
+
fi
|
| 404 |
+
|
| 405 |
+
cat >"${manifest_path}" <<EOF
|
| 406 |
+
{
|
| 407 |
+
"profile": "$(json_escape "${profile}")",
|
| 408 |
+
"preset": "persistent-docker",
|
| 409 |
+
"runtime_kind": "docker",
|
| 410 |
+
"supervisor_kind": "none",
|
| 411 |
+
"scope": "user",
|
| 412 |
+
"provider_mode": "manual",
|
| 413 |
+
"targets": [],
|
| 414 |
+
"port": ${port},
|
| 415 |
+
"host": "127.0.0.1",
|
| 416 |
+
"backend": "$(json_escape "${backend}")",
|
| 417 |
+
"anyllm_provider": ${anyllm_json},
|
| 418 |
+
"region": ${region_json},
|
| 419 |
+
"proxy_mode": "$(json_escape "${proxy_mode}")",
|
| 420 |
+
"memory_enabled": ${memory_json},
|
| 421 |
+
"memory_db_path": "$(json_escape "${HEADROOM_CONTAINER_HOME}/.headroom/memory.db")",
|
| 422 |
+
"telemetry_enabled": ${telemetry_json},
|
| 423 |
+
"image": "$(json_escape "${image}")",
|
| 424 |
+
"service_name": "headroom-$(json_escape "${profile}")",
|
| 425 |
+
"container_name": "$(json_escape "$(persistent_container_name "${profile}")")",
|
| 426 |
+
"health_url": "http://127.0.0.1:${port}/readyz",
|
| 427 |
+
"base_env": {
|
| 428 |
+
"HEADROOM_PORT": "${port}",
|
| 429 |
+
"HEADROOM_HOST": "127.0.0.1",
|
| 430 |
+
"HEADROOM_MODE": "$(json_escape "${proxy_mode}")",
|
| 431 |
+
"HEADROOM_BACKEND": "$(json_escape "${backend}")"
|
| 432 |
+
},
|
| 433 |
+
"tool_envs": {},
|
| 434 |
+
"proxy_args": $(json_array_from_args "${proxy_args_ref[@]}"),
|
| 435 |
+
"mutations": [],
|
| 436 |
+
"artifacts": []
|
| 437 |
+
}
|
| 438 |
+
EOF
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
load_persistent_state() {
|
| 442 |
+
local profile="$1"
|
| 443 |
+
local state_path
|
| 444 |
+
validate_profile_name "${profile}"
|
| 445 |
+
state_path="$(persistent_state_path "${profile}")"
|
| 446 |
+
[[ -f "${state_path}" ]] || die "No docker-native persistent deployment profile named '${profile}'"
|
| 447 |
+
PROFILE=""
|
| 448 |
+
IMAGE=""
|
| 449 |
+
PORT=""
|
| 450 |
+
BACKEND=""
|
| 451 |
+
ANYLLM_PROVIDER=""
|
| 452 |
+
REGION=""
|
| 453 |
+
PROXY_MODE=""
|
| 454 |
+
MEMORY_ENABLED=""
|
| 455 |
+
TELEMETRY_ENABLED=""
|
| 456 |
+
CONTAINER_NAME=""
|
| 457 |
+
HEALTH_URL=""
|
| 458 |
+
while IFS='=' read -r key value; do
|
| 459 |
+
case "${key}" in
|
| 460 |
+
PROFILE|IMAGE|PORT|BACKEND|ANYLLM_PROVIDER|REGION|PROXY_MODE|MEMORY_ENABLED|TELEMETRY_ENABLED|CONTAINER_NAME|HEALTH_URL)
|
| 461 |
+
printf -v "${key}" '%s' "${value}"
|
| 462 |
+
;;
|
| 463 |
+
esac
|
| 464 |
+
done <"${state_path}"
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
start_persistent_docker_install() {
|
| 468 |
+
local profile="$1"
|
| 469 |
+
local image="$2"
|
| 470 |
+
local port="$3"
|
| 471 |
+
local backend="$4"
|
| 472 |
+
local anyllm="$5"
|
| 473 |
+
local region="$6"
|
| 474 |
+
local proxy_mode="$7"
|
| 475 |
+
local memory_enabled="$8"
|
| 476 |
+
local telemetry_enabled="$9"
|
| 477 |
+
|
| 478 |
+
local container_name
|
| 479 |
+
local proxy_args=()
|
| 480 |
+
local args=()
|
| 481 |
+
|
| 482 |
+
validate_profile_name "${profile}"
|
| 483 |
+
container_name="$(persistent_container_name "${profile}")"
|
| 484 |
+
build_manifest_proxy_args proxy_args "${port}" "${proxy_mode}" "${backend}" "${anyllm}" "${region}" "${memory_enabled}" "${telemetry_enabled}"
|
| 485 |
+
|
| 486 |
+
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
| 487 |
+
|
| 488 |
+
args=(docker run -d --restart unless-stopped --name "${container_name}" -p "${port}:${port}")
|
| 489 |
+
append_persistent_container_args args
|
| 490 |
+
args+=("${image}" --host 0.0.0.0 "${proxy_args[@]:2}")
|
| 491 |
+
"${args[@]}" >/dev/null
|
| 492 |
+
|
| 493 |
+
if ! wait_for_proxy "${container_name}" "${port}"; then
|
| 494 |
+
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
| 495 |
+
die "Headroom persistent Docker deployment failed to start on port ${port}"
|
| 496 |
+
fi
|
| 497 |
+
|
| 498 |
+
write_persistent_state "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
|
| 499 |
+
write_persistent_manifest "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}" proxy_args
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
stop_persistent_docker_install() {
|
| 503 |
+
local profile="$1"
|
| 504 |
+
local container_name
|
| 505 |
+
|
| 506 |
+
load_persistent_state "${profile}"
|
| 507 |
+
container_name="${CONTAINER_NAME}"
|
| 508 |
+
docker stop "${container_name}" >/dev/null 2>&1 || true
|
| 509 |
+
docker rm -f "${container_name}" >/dev/null 2>&1 || true
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
status_persistent_docker_install() {
|
| 513 |
+
local profile="$1"
|
| 514 |
+
local status="stopped"
|
| 515 |
+
local ready="no"
|
| 516 |
+
|
| 517 |
+
load_persistent_state "${profile}"
|
| 518 |
+
if docker_container_exists "${CONTAINER_NAME}"; then
|
| 519 |
+
status="running"
|
| 520 |
+
if command -v curl >/dev/null 2>&1; then
|
| 521 |
+
if curl --fail --silent "${HEALTH_URL}" >/dev/null; then
|
| 522 |
+
ready="yes"
|
| 523 |
+
fi
|
| 524 |
+
elif (echo >/dev/tcp/127.0.0.1/"${PORT}") >/dev/null 2>&1; then
|
| 525 |
+
ready="yes"
|
| 526 |
+
fi
|
| 527 |
+
fi
|
| 528 |
+
|
| 529 |
+
printf 'Profile: %s\n' "${PROFILE}"
|
| 530 |
+
printf 'Preset: persistent-docker\n'
|
| 531 |
+
printf 'Runtime: docker\n'
|
| 532 |
+
printf 'Supervisor: none\n'
|
| 533 |
+
printf 'Port: %s\n' "${PORT}"
|
| 534 |
+
printf 'Status: %s\n' "${status}"
|
| 535 |
+
printf 'Ready: %s\n' "${ready}"
|
| 536 |
+
printf 'Health URL: %s\n' "${HEALTH_URL}"
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
remove_persistent_docker_install() {
|
| 540 |
+
local profile="$1"
|
| 541 |
+
local root
|
| 542 |
+
|
| 543 |
+
load_persistent_state "${profile}"
|
| 544 |
+
docker stop "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
| 545 |
+
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
| 546 |
+
root="$(persistent_profile_root "${profile}")"
|
| 547 |
+
rm -rf "${root}"
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
print_install_help() {
|
| 551 |
+
cat <<'EOF'
|
| 552 |
+
Usage: headroom install [OPTIONS] COMMAND [ARGS]...
|
| 553 |
+
|
| 554 |
+
Manage persistent Docker-native Headroom deployments.
|
| 555 |
+
|
| 556 |
+
The Docker-native wrapper currently supports the persistent-docker preset only.
|
| 557 |
+
Use the Python-native `headroom install` command for persistent-service and
|
| 558 |
+
persistent-task installs, or when you need provider/user/system config mutation.
|
| 559 |
+
|
| 560 |
+
Options:
|
| 561 |
+
-?, --help Show this message and exit.
|
| 562 |
+
|
| 563 |
+
Commands:
|
| 564 |
+
apply Install a persistent Docker deployment.
|
| 565 |
+
remove Remove a persistent Docker deployment.
|
| 566 |
+
restart Restart a persistent Docker deployment.
|
| 567 |
+
start Start a persistent Docker deployment.
|
| 568 |
+
status Show persistent Docker deployment status.
|
| 569 |
+
stop Stop a persistent Docker deployment.
|
| 570 |
+
EOF
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
print_install_apply_help() {
|
| 574 |
+
cat <<'EOF'
|
| 575 |
+
Usage: headroom install apply [OPTIONS]
|
| 576 |
+
|
| 577 |
+
Install a persistent Docker deployment.
|
| 578 |
+
|
| 579 |
+
Options:
|
| 580 |
+
--preset [persistent-docker] Docker-native wrapper supports persistent-docker only.
|
| 581 |
+
--runtime [docker] Docker-native wrapper supports runtime=docker only.
|
| 582 |
+
--profile TEXT Deployment profile name. [default: default]
|
| 583 |
+
-p, --port INTEGER Persistent proxy port. [default: 8787]
|
| 584 |
+
--backend TEXT Proxy backend. [default: anthropic]
|
| 585 |
+
--anyllm-provider TEXT Provider for any-llm backends.
|
| 586 |
+
--region TEXT Cloud region for Bedrock / Vertex style backends.
|
| 587 |
+
--mode TEXT Proxy optimization mode. [default: token]
|
| 588 |
+
--memory Enable persistent memory in the runtime.
|
| 589 |
+
--no-telemetry Disable anonymous telemetry in the runtime.
|
| 590 |
+
--image TEXT Docker image to use. [default: HEADROOM_DOCKER_IMAGE or ghcr.io/chopratejas/headroom:latest]
|
| 591 |
+
-?, --help Show this message and exit.
|
| 592 |
+
EOF
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
parse_install_apply_args() {
|
| 596 |
+
local -n out_profile=$1
|
| 597 |
+
local -n out_port=$2
|
| 598 |
+
local -n out_backend=$3
|
| 599 |
+
local -n out_anyllm=$4
|
| 600 |
+
local -n out_region=$5
|
| 601 |
+
local -n out_mode=$6
|
| 602 |
+
local -n out_memory=$7
|
| 603 |
+
local -n out_telemetry=$8
|
| 604 |
+
local -n out_image=$9
|
| 605 |
+
shift 9
|
| 606 |
+
|
| 607 |
+
out_profile="default"
|
| 608 |
+
out_port=8787
|
| 609 |
+
out_backend="anthropic"
|
| 610 |
+
out_anyllm=""
|
| 611 |
+
out_region=""
|
| 612 |
+
out_mode="token"
|
| 613 |
+
out_memory=0
|
| 614 |
+
out_telemetry=1
|
| 615 |
+
out_image="${HEADROOM_IMAGE}"
|
| 616 |
+
|
| 617 |
+
while (($#)); do
|
| 618 |
+
case "$1" in
|
| 619 |
+
--preset)
|
| 620 |
+
require_option_value "$@"
|
| 621 |
+
[[ "$2" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
|
| 622 |
+
shift 2
|
| 623 |
+
;;
|
| 624 |
+
--preset=*)
|
| 625 |
+
[[ "${1#*=}" == "persistent-docker" ]] || die "Docker-native wrapper supports only --preset persistent-docker"
|
| 626 |
+
shift
|
| 627 |
+
;;
|
| 628 |
+
--runtime)
|
| 629 |
+
require_option_value "$@"
|
| 630 |
+
[[ "$2" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
|
| 631 |
+
shift 2
|
| 632 |
+
;;
|
| 633 |
+
--runtime=*)
|
| 634 |
+
[[ "${1#*=}" == "docker" ]] || die "Docker-native wrapper supports only --runtime docker"
|
| 635 |
+
shift
|
| 636 |
+
;;
|
| 637 |
+
--scope|--providers|--target)
|
| 638 |
+
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
|
| 639 |
+
;;
|
| 640 |
+
--scope=*|--providers=*|--target=*)
|
| 641 |
+
die "Docker-native wrapper install does not support provider/user/system mutation flags; use the Python-native CLI for those flows"
|
| 642 |
+
;;
|
| 643 |
+
--profile)
|
| 644 |
+
require_option_value "$@"
|
| 645 |
+
out_profile="$2"
|
| 646 |
+
shift 2
|
| 647 |
+
;;
|
| 648 |
+
--profile=*)
|
| 649 |
+
out_profile="${1#*=}"
|
| 650 |
+
shift
|
| 651 |
+
;;
|
| 652 |
+
--port|-p)
|
| 653 |
+
require_option_value "$@"
|
| 654 |
+
out_port="$2"
|
| 655 |
+
shift 2
|
| 656 |
+
;;
|
| 657 |
+
--port=*|-p=*)
|
| 658 |
+
out_port="${1#*=}"
|
| 659 |
+
shift
|
| 660 |
+
;;
|
| 661 |
+
--backend)
|
| 662 |
+
require_option_value "$@"
|
| 663 |
+
out_backend="$2"
|
| 664 |
+
shift 2
|
| 665 |
+
;;
|
| 666 |
+
--backend=*)
|
| 667 |
+
out_backend="${1#*=}"
|
| 668 |
+
shift
|
| 669 |
+
;;
|
| 670 |
+
--anyllm-provider)
|
| 671 |
+
require_option_value "$@"
|
| 672 |
+
out_anyllm="$2"
|
| 673 |
+
shift 2
|
| 674 |
+
;;
|
| 675 |
+
--anyllm-provider=*)
|
| 676 |
+
out_anyllm="${1#*=}"
|
| 677 |
+
shift
|
| 678 |
+
;;
|
| 679 |
+
--region)
|
| 680 |
+
require_option_value "$@"
|
| 681 |
+
out_region="$2"
|
| 682 |
+
shift 2
|
| 683 |
+
;;
|
| 684 |
+
--region=*)
|
| 685 |
+
out_region="${1#*=}"
|
| 686 |
+
shift
|
| 687 |
+
;;
|
| 688 |
+
--mode)
|
| 689 |
+
require_option_value "$@"
|
| 690 |
+
out_mode="$2"
|
| 691 |
+
shift 2
|
| 692 |
+
;;
|
| 693 |
+
--mode=*)
|
| 694 |
+
out_mode="${1#*=}"
|
| 695 |
+
shift
|
| 696 |
+
;;
|
| 697 |
+
--memory)
|
| 698 |
+
out_memory=1
|
| 699 |
+
shift
|
| 700 |
+
;;
|
| 701 |
+
--no-telemetry)
|
| 702 |
+
out_telemetry=0
|
| 703 |
+
shift
|
| 704 |
+
;;
|
| 705 |
+
--image)
|
| 706 |
+
require_option_value "$@"
|
| 707 |
+
out_image="$2"
|
| 708 |
+
shift 2
|
| 709 |
+
;;
|
| 710 |
+
--image=*)
|
| 711 |
+
out_image="${1#*=}"
|
| 712 |
+
shift
|
| 713 |
+
;;
|
| 714 |
+
--help|-?)
|
| 715 |
+
print_install_apply_help
|
| 716 |
+
exit 0
|
| 717 |
+
;;
|
| 718 |
+
*)
|
| 719 |
+
die "Unsupported option for 'headroom install apply': $1"
|
| 720 |
+
;;
|
| 721 |
+
esac
|
| 722 |
+
done
|
| 723 |
+
|
| 724 |
+
validate_port "${out_port}"
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
parse_install_profile_arg() {
|
| 728 |
+
local -n out_profile=$1
|
| 729 |
+
shift
|
| 730 |
+
|
| 731 |
+
out_profile="default"
|
| 732 |
+
while (($#)); do
|
| 733 |
+
case "$1" in
|
| 734 |
+
--profile)
|
| 735 |
+
require_option_value "$@"
|
| 736 |
+
out_profile="$2"
|
| 737 |
+
shift 2
|
| 738 |
+
;;
|
| 739 |
+
--profile=*)
|
| 740 |
+
out_profile="${1#*=}"
|
| 741 |
+
shift
|
| 742 |
+
;;
|
| 743 |
+
--help|-?)
|
| 744 |
+
print_install_help
|
| 745 |
+
exit 0
|
| 746 |
+
;;
|
| 747 |
+
*)
|
| 748 |
+
die "Unsupported option for 'headroom install': $1"
|
| 749 |
+
;;
|
| 750 |
+
esac
|
| 751 |
+
done
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
run_claude_rtk_init() {
|
| 755 |
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
|
| 756 |
if [[ ! -x "${rtk_bin}" ]]; then
|
|
|
|
| 793 |
break
|
| 794 |
;;
|
| 795 |
--port|-p)
|
| 796 |
+
require_option_value "$@"
|
| 797 |
out_port="$2"
|
| 798 |
+
validate_port "${out_port}"
|
| 799 |
out_known+=("$1" "$2")
|
| 800 |
shift 2
|
| 801 |
;;
|
| 802 |
--port=*)
|
| 803 |
out_port="${1#*=}"
|
| 804 |
+
validate_port "${out_port}"
|
| 805 |
out_known+=("$1")
|
| 806 |
shift
|
| 807 |
;;
|
|
|
|
| 825 |
shift
|
| 826 |
;;
|
| 827 |
--backend)
|
| 828 |
+
require_option_value "$@"
|
| 829 |
out_backend="$2"
|
| 830 |
out_known+=("$1" "$2")
|
| 831 |
shift 2
|
|
|
|
| 836 |
shift
|
| 837 |
;;
|
| 838 |
--anyllm-provider)
|
| 839 |
+
require_option_value "$@"
|
| 840 |
out_anyllm="$2"
|
| 841 |
out_known+=("$1" "$2")
|
| 842 |
shift 2
|
|
|
|
| 847 |
shift
|
| 848 |
;;
|
| 849 |
--region)
|
| 850 |
+
require_option_value "$@"
|
| 851 |
out_region="$2"
|
| 852 |
out_known+=("$1" "$2")
|
| 853 |
shift 2
|
|
|
|
| 929 |
while (($#)); do
|
| 930 |
case "$1" in
|
| 931 |
--plugin-path)
|
| 932 |
+
require_option_value "$@"
|
| 933 |
out_plugin_path="$2"
|
| 934 |
shift 2
|
| 935 |
;;
|
|
|
|
| 938 |
shift
|
| 939 |
;;
|
| 940 |
--plugin-spec)
|
| 941 |
+
require_option_value "$@"
|
| 942 |
out_plugin_spec="$2"
|
| 943 |
shift 2
|
| 944 |
;;
|
|
|
|
| 955 |
shift
|
| 956 |
;;
|
| 957 |
--proxy-port)
|
| 958 |
+
require_option_value "$@"
|
| 959 |
out_proxy_port="$2"
|
| 960 |
+
validate_port "${out_proxy_port}"
|
| 961 |
shift 2
|
| 962 |
;;
|
| 963 |
--proxy-port=*)
|
| 964 |
out_proxy_port="${1#*=}"
|
| 965 |
+
validate_port "${out_proxy_port}"
|
| 966 |
shift
|
| 967 |
;;
|
| 968 |
--startup-timeout-ms)
|
| 969 |
+
require_option_value "$@"
|
| 970 |
out_startup_timeout_ms="$2"
|
| 971 |
+
validate_positive_integer "${out_startup_timeout_ms}"
|
| 972 |
shift 2
|
| 973 |
;;
|
| 974 |
--startup-timeout-ms=*)
|
| 975 |
out_startup_timeout_ms="${1#*=}"
|
| 976 |
+
validate_positive_integer "${out_startup_timeout_ms}"
|
| 977 |
shift
|
| 978 |
;;
|
| 979 |
--gateway-provider-id)
|
| 980 |
+
require_option_value "$@"
|
| 981 |
out_gateway_provider_ids+=("$2")
|
| 982 |
shift 2
|
| 983 |
;;
|
|
|
|
| 986 |
shift
|
| 987 |
;;
|
| 988 |
--python-path)
|
| 989 |
+
require_option_value "$@"
|
| 990 |
out_python_path="$2"
|
| 991 |
shift 2
|
| 992 |
;;
|
|
|
|
| 1353 |
fi
|
| 1354 |
|
| 1355 |
case "$1" in
|
| 1356 |
+
install)
|
| 1357 |
+
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
|
| 1358 |
+
print_install_help
|
| 1359 |
+
return
|
| 1360 |
+
fi
|
| 1361 |
+
|
| 1362 |
+
local install_command="$2"
|
| 1363 |
+
shift 2
|
| 1364 |
+
case "${install_command}" in
|
| 1365 |
+
apply)
|
| 1366 |
+
local profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image
|
| 1367 |
+
parse_install_apply_args profile port backend anyllm region proxy_mode memory_enabled telemetry_enabled image "$@"
|
| 1368 |
+
start_persistent_docker_install "${profile}" "${image}" "${port}" "${backend}" "${anyllm}" "${region}" "${proxy_mode}" "${memory_enabled}" "${telemetry_enabled}"
|
| 1369 |
+
printf "Installed docker-native persistent deployment '%s' on port %s.\n" "${profile}" "${port}"
|
| 1370 |
+
;;
|
| 1371 |
+
status)
|
| 1372 |
+
local profile
|
| 1373 |
+
parse_install_profile_arg profile "$@"
|
| 1374 |
+
status_persistent_docker_install "${profile}"
|
| 1375 |
+
;;
|
| 1376 |
+
start)
|
| 1377 |
+
local profile
|
| 1378 |
+
parse_install_profile_arg profile "$@"
|
| 1379 |
+
load_persistent_state "${profile}"
|
| 1380 |
+
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
|
| 1381 |
+
printf "Started docker-native persistent deployment '%s'.\n" "${profile}"
|
| 1382 |
+
;;
|
| 1383 |
+
stop)
|
| 1384 |
+
local profile
|
| 1385 |
+
parse_install_profile_arg profile "$@"
|
| 1386 |
+
stop_persistent_docker_install "${profile}"
|
| 1387 |
+
printf "Stopped docker-native persistent deployment '%s'.\n" "${profile}"
|
| 1388 |
+
;;
|
| 1389 |
+
restart)
|
| 1390 |
+
local profile
|
| 1391 |
+
parse_install_profile_arg profile "$@"
|
| 1392 |
+
load_persistent_state "${profile}"
|
| 1393 |
+
start_persistent_docker_install "${PROFILE}" "${IMAGE}" "${PORT}" "${BACKEND}" "${ANYLLM_PROVIDER}" "${REGION}" "${PROXY_MODE}" "${MEMORY_ENABLED}" "${TELEMETRY_ENABLED}"
|
| 1394 |
+
printf "Restarted docker-native persistent deployment '%s'.\n" "${profile}"
|
| 1395 |
+
;;
|
| 1396 |
+
remove)
|
| 1397 |
+
local profile
|
| 1398 |
+
parse_install_profile_arg profile "$@"
|
| 1399 |
+
remove_persistent_docker_install "${profile}"
|
| 1400 |
+
printf "Removed docker-native persistent deployment '%s'.\n" "${profile}"
|
| 1401 |
+
;;
|
| 1402 |
+
*)
|
| 1403 |
+
die "Unsupported install target: ${install_command}"
|
| 1404 |
+
;;
|
| 1405 |
+
esac
|
| 1406 |
+
;;
|
| 1407 |
wrap)
|
| 1408 |
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
|
| 1409 |
run_headroom wrap --help
|
|
|
|
| 1520 |
while (($#)); do
|
| 1521 |
case "$1" in
|
| 1522 |
--port|-p)
|
| 1523 |
+
require_option_value "$@"
|
| 1524 |
port="$2"
|
| 1525 |
+
validate_port "${port}"
|
| 1526 |
args+=("$1" "$2")
|
| 1527 |
shift 2
|
| 1528 |
;;
|
| 1529 |
--port=*)
|
| 1530 |
port="${1#*=}"
|
| 1531 |
+
validate_port "${port}"
|
| 1532 |
args+=("$1")
|
| 1533 |
shift
|
| 1534 |
;;
|
|
|
|
| 1554 |
|
| 1555 |
main "$@"
|
| 1556 |
WRAPPER
|
| 1557 |
+
} >"${wrapper_path}"
|
| 1558 |
|
| 1559 |
chmod +x "${wrapper_path}"
|
| 1560 |
}
|
|
|
|
| 1570 |
append_path_block "${HOME}/.zshrc"
|
| 1571 |
append_path_block "${HOME}/.profile"
|
| 1572 |
|
| 1573 |
+
if [[ -n "${HEADROOM_DOCKER_IMAGE:-}" ]]; then
|
| 1574 |
+
if docker image inspect "${INSTALL_IMAGE}" >/dev/null 2>&1; then
|
| 1575 |
+
info "Using existing HEADROOM_DOCKER_IMAGE=${INSTALL_IMAGE}"
|
| 1576 |
+
else
|
| 1577 |
+
info "Pulling ${INSTALL_IMAGE}"
|
| 1578 |
+
docker pull "${INSTALL_IMAGE}" >/dev/null
|
| 1579 |
+
fi
|
| 1580 |
+
else
|
| 1581 |
+
info "Pulling ${IMAGE_DEFAULT}"
|
| 1582 |
+
docker pull "${IMAGE_DEFAULT}" >/dev/null
|
| 1583 |
+
fi
|
| 1584 |
|
| 1585 |
cat <<EOF
|
| 1586 |
|
tests/test_install/test_native_installers.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import signal
|
| 7 |
+
import socket
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 15 |
+
|
| 16 |
+
FAKE_DOCKER = r"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import signal
|
| 22 |
+
import subprocess
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
STATE_PATH = Path(os.environ["FAKE_DOCKER_STATE"])
|
| 28 |
+
LOG_PATH = Path(os.environ["FAKE_DOCKER_LOG"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_state() -> dict[str, dict[str, dict[str, int]]]:
|
| 32 |
+
if not STATE_PATH.exists():
|
| 33 |
+
return {"containers": {}}
|
| 34 |
+
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_state(state: dict[str, dict[str, dict[str, int]]]) -> None:
|
| 38 |
+
STATE_PATH.write_text(json.dumps(state), encoding="utf-8")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def cleanup_dead(state: dict[str, dict[str, dict[str, int]]]) -> dict[str, dict[str, dict[str, int]]]:
|
| 42 |
+
save_state(state)
|
| 43 |
+
return state
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def host_port_from_publish(value: str) -> int:
|
| 47 |
+
parts = value.split(":")
|
| 48 |
+
if len(parts) == 2:
|
| 49 |
+
return int(parts[0])
|
| 50 |
+
if len(parts) >= 3:
|
| 51 |
+
return int(parts[-2])
|
| 52 |
+
raise ValueError(f"Unsupported publish value: {value}")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def start_server(port: int) -> int:
|
| 56 |
+
code = '''
|
| 57 |
+
import sys
|
| 58 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 59 |
+
|
| 60 |
+
port = int(sys.argv[1])
|
| 61 |
+
|
| 62 |
+
class Handler(BaseHTTPRequestHandler):
|
| 63 |
+
def do_GET(self):
|
| 64 |
+
self.send_response(200)
|
| 65 |
+
self.send_header("Content-Type", "text/plain")
|
| 66 |
+
self.end_headers()
|
| 67 |
+
self.wfile.write(b"ok")
|
| 68 |
+
|
| 69 |
+
def log_message(self, fmt, *args):
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
| 73 |
+
'''
|
| 74 |
+
process = subprocess.Popen(
|
| 75 |
+
[sys.executable, "-c", code, str(port)],
|
| 76 |
+
stdout=subprocess.DEVNULL,
|
| 77 |
+
stderr=subprocess.DEVNULL,
|
| 78 |
+
)
|
| 79 |
+
return process.pid
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def stop_container(state: dict[str, dict[str, dict[str, int]]], name: str) -> None:
|
| 83 |
+
data = state["containers"].pop(name, None)
|
| 84 |
+
if not data:
|
| 85 |
+
return
|
| 86 |
+
try:
|
| 87 |
+
os.kill(int(data["pid"]), signal.SIGTERM)
|
| 88 |
+
except OSError:
|
| 89 |
+
pass
|
| 90 |
+
save_state(state)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def main() -> int:
|
| 94 |
+
args = sys.argv[1:]
|
| 95 |
+
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
with LOG_PATH.open("a", encoding="utf-8") as handle:
|
| 97 |
+
handle.write(json.dumps(args) + "\n")
|
| 98 |
+
|
| 99 |
+
if not args:
|
| 100 |
+
return 0
|
| 101 |
+
|
| 102 |
+
state = cleanup_dead(load_state())
|
| 103 |
+
command = args[0]
|
| 104 |
+
|
| 105 |
+
if command == "pull":
|
| 106 |
+
return 0
|
| 107 |
+
|
| 108 |
+
if command == "run":
|
| 109 |
+
detached = "-d" in args
|
| 110 |
+
if not detached:
|
| 111 |
+
return 0
|
| 112 |
+
|
| 113 |
+
name = None
|
| 114 |
+
publish = None
|
| 115 |
+
for index, arg in enumerate(args):
|
| 116 |
+
if arg == "--name":
|
| 117 |
+
name = args[index + 1]
|
| 118 |
+
elif arg == "-p":
|
| 119 |
+
publish = args[index + 1]
|
| 120 |
+
|
| 121 |
+
if name is None or publish is None:
|
| 122 |
+
raise SystemExit("missing --name or -p in fake docker run")
|
| 123 |
+
|
| 124 |
+
port = host_port_from_publish(publish)
|
| 125 |
+
state["containers"][name] = {"pid": start_server(port), "port": port}
|
| 126 |
+
save_state(state)
|
| 127 |
+
print(name)
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
if command == "ps":
|
| 131 |
+
names = sorted(state["containers"])
|
| 132 |
+
if "--format" in args:
|
| 133 |
+
print("\n".join(names))
|
| 134 |
+
return 0
|
| 135 |
+
|
| 136 |
+
if command == "stop":
|
| 137 |
+
for name in args[1:]:
|
| 138 |
+
if not name.startswith("-"):
|
| 139 |
+
stop_container(state, name)
|
| 140 |
+
return 0
|
| 141 |
+
|
| 142 |
+
if command == "rm":
|
| 143 |
+
for name in args[1:]:
|
| 144 |
+
if not name.startswith("-"):
|
| 145 |
+
stop_container(state, name)
|
| 146 |
+
return 0
|
| 147 |
+
|
| 148 |
+
if command == "logs":
|
| 149 |
+
if len(args) > 1:
|
| 150 |
+
print(f"fake logs for {args[1]}")
|
| 151 |
+
return 0
|
| 152 |
+
|
| 153 |
+
return 0
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
raise SystemExit(main())
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _free_port() -> int:
|
| 162 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 163 |
+
sock.bind(("127.0.0.1", 0))
|
| 164 |
+
return int(sock.getsockname()[1])
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _write_fake_docker_shims(tmp_path: Path) -> Path:
|
| 168 |
+
shim_dir = tmp_path / "fake-docker"
|
| 169 |
+
shim_dir.mkdir()
|
| 170 |
+
|
| 171 |
+
fake_docker = shim_dir / "fake_docker.py"
|
| 172 |
+
fake_docker.write_text(FAKE_DOCKER, encoding="utf-8")
|
| 173 |
+
|
| 174 |
+
docker_sh = shim_dir / "docker"
|
| 175 |
+
docker_sh.write_text(
|
| 176 |
+
f'#!/usr/bin/env bash\nexec "{sys.executable}" "{fake_docker}" "$@"\n',
|
| 177 |
+
encoding="utf-8",
|
| 178 |
+
)
|
| 179 |
+
docker_sh.chmod(0o755)
|
| 180 |
+
|
| 181 |
+
docker_cmd = shim_dir / "docker.cmd"
|
| 182 |
+
docker_cmd.write_text(
|
| 183 |
+
f'@echo off\r\n"{sys.executable}" "{fake_docker}" %*\r\n',
|
| 184 |
+
encoding="utf-8",
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
openclaw_sh = shim_dir / "openclaw"
|
| 188 |
+
openclaw_sh.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
| 189 |
+
openclaw_sh.chmod(0o755)
|
| 190 |
+
|
| 191 |
+
openclaw_cmd = shim_dir / "openclaw.cmd"
|
| 192 |
+
openclaw_cmd.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8")
|
| 193 |
+
|
| 194 |
+
return shim_dir
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _build_env(home: Path, tmp_path: Path) -> dict[str, str]:
|
| 198 |
+
env = os.environ.copy()
|
| 199 |
+
shim_dir = _write_fake_docker_shims(tmp_path)
|
| 200 |
+
env["HOME"] = str(home)
|
| 201 |
+
env["USERPROFILE"] = str(home)
|
| 202 |
+
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
|
| 203 |
+
env["FAKE_DOCKER_STATE"] = str(tmp_path / "fake-docker-state.json")
|
| 204 |
+
env["FAKE_DOCKER_LOG"] = str(tmp_path / "fake-docker.log")
|
| 205 |
+
return env
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _cleanup_fake_docker(env: dict[str, str]) -> None:
|
| 209 |
+
state_path = Path(env["FAKE_DOCKER_STATE"])
|
| 210 |
+
if not state_path.exists():
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
| 214 |
+
for container in state.get("containers", {}).values():
|
| 215 |
+
try:
|
| 216 |
+
os.kill(int(container["pid"]), signal.SIGTERM)
|
| 217 |
+
except OSError:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _read_fake_docker_log(env: dict[str, str]) -> list[list[str]]:
|
| 222 |
+
log_path = Path(env["FAKE_DOCKER_LOG"])
|
| 223 |
+
if not log_path.exists():
|
| 224 |
+
return []
|
| 225 |
+
return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _run(
|
| 229 |
+
command: list[str],
|
| 230 |
+
*,
|
| 231 |
+
env: dict[str, str],
|
| 232 |
+
cwd: Path | None = None,
|
| 233 |
+
check: bool = True,
|
| 234 |
+
) -> subprocess.CompletedProcess[str]:
|
| 235 |
+
return subprocess.run(
|
| 236 |
+
command,
|
| 237 |
+
cwd=cwd,
|
| 238 |
+
env=env,
|
| 239 |
+
capture_output=True,
|
| 240 |
+
text=True,
|
| 241 |
+
check=check,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@pytest.mark.skipif(
|
| 246 |
+
os.name == "nt" or shutil.which("bash") is None,
|
| 247 |
+
reason="bash installer coverage runs on non-Windows hosts",
|
| 248 |
+
)
|
| 249 |
+
def test_bash_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
|
| 250 |
+
home = tmp_path / "home"
|
| 251 |
+
(home / ".local").mkdir(parents=True)
|
| 252 |
+
env = _build_env(home, tmp_path)
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
_run(["bash", str(REPO_ROOT / "scripts" / "install.sh")], env=env, cwd=REPO_ROOT)
|
| 256 |
+
|
| 257 |
+
wrapper = home / ".local" / "bin" / "headroom"
|
| 258 |
+
assert wrapper.exists()
|
| 259 |
+
|
| 260 |
+
help_result = _run([str(wrapper), "install", "-?"], env=env)
|
| 261 |
+
assert "persistent-docker preset only" in help_result.stdout
|
| 262 |
+
_run([str(wrapper), "--help"], env=env)
|
| 263 |
+
|
| 264 |
+
invalid_profile = _run(
|
| 265 |
+
[str(wrapper), "install", "status", "--profile", ".."],
|
| 266 |
+
env=env,
|
| 267 |
+
check=False,
|
| 268 |
+
)
|
| 269 |
+
assert invalid_profile.returncode != 0
|
| 270 |
+
assert "Invalid profile name '..'" in invalid_profile.stderr
|
| 271 |
+
missing_profile_value = _run(
|
| 272 |
+
[str(wrapper), "install", "apply", "--profile"],
|
| 273 |
+
env=env,
|
| 274 |
+
check=False,
|
| 275 |
+
)
|
| 276 |
+
assert missing_profile_value.returncode != 0
|
| 277 |
+
assert "Option --profile requires a value" in missing_profile_value.stderr
|
| 278 |
+
missing_proxy_port = _run(
|
| 279 |
+
[str(wrapper), "proxy", "--port"],
|
| 280 |
+
env=env,
|
| 281 |
+
check=False,
|
| 282 |
+
)
|
| 283 |
+
assert missing_proxy_port.returncode != 0
|
| 284 |
+
assert "Option --port requires a value" in missing_proxy_port.stderr
|
| 285 |
+
invalid_proxy_port = _run(
|
| 286 |
+
[str(wrapper), "proxy", "--port", "abc"],
|
| 287 |
+
env=env,
|
| 288 |
+
check=False,
|
| 289 |
+
)
|
| 290 |
+
assert invalid_proxy_port.returncode != 0
|
| 291 |
+
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
|
| 292 |
+
missing_wrap_port = _run(
|
| 293 |
+
[str(wrapper), "wrap", "claude", "--port"],
|
| 294 |
+
env=env,
|
| 295 |
+
check=False,
|
| 296 |
+
)
|
| 297 |
+
assert missing_wrap_port.returncode != 0
|
| 298 |
+
assert "Option --port requires a value" in missing_wrap_port.stderr
|
| 299 |
+
invalid_wrap_port = _run(
|
| 300 |
+
[str(wrapper), "wrap", "claude", "--port", "abc"],
|
| 301 |
+
env=env,
|
| 302 |
+
check=False,
|
| 303 |
+
)
|
| 304 |
+
assert invalid_wrap_port.returncode != 0
|
| 305 |
+
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
|
| 306 |
+
missing_openclaw_proxy_port = _run(
|
| 307 |
+
[str(wrapper), "wrap", "openclaw", "--proxy-port"],
|
| 308 |
+
env=env,
|
| 309 |
+
check=False,
|
| 310 |
+
)
|
| 311 |
+
assert missing_openclaw_proxy_port.returncode != 0
|
| 312 |
+
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
|
| 313 |
+
invalid_openclaw_proxy_port = _run(
|
| 314 |
+
[str(wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
|
| 315 |
+
env=env,
|
| 316 |
+
check=False,
|
| 317 |
+
)
|
| 318 |
+
assert invalid_openclaw_proxy_port.returncode != 0
|
| 319 |
+
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
|
| 320 |
+
for invalid_port in ("abc", "0", "65536"):
|
| 321 |
+
invalid_port_result = _run(
|
| 322 |
+
[str(wrapper), "install", "apply", "--port", invalid_port],
|
| 323 |
+
env=env,
|
| 324 |
+
check=False,
|
| 325 |
+
)
|
| 326 |
+
assert invalid_port_result.returncode != 0
|
| 327 |
+
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
|
| 328 |
+
|
| 329 |
+
port = _free_port()
|
| 330 |
+
_run(
|
| 331 |
+
[
|
| 332 |
+
str(wrapper),
|
| 333 |
+
"install",
|
| 334 |
+
"apply",
|
| 335 |
+
"--profile",
|
| 336 |
+
"smoke",
|
| 337 |
+
"--port",
|
| 338 |
+
str(port),
|
| 339 |
+
"--memory",
|
| 340 |
+
"--no-telemetry",
|
| 341 |
+
"--image",
|
| 342 |
+
"fake/headroom:test",
|
| 343 |
+
],
|
| 344 |
+
env=env,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
|
| 348 |
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 349 |
+
assert manifest["preset"] == "persistent-docker"
|
| 350 |
+
assert manifest["port"] == port
|
| 351 |
+
assert manifest["memory_enabled"] is True
|
| 352 |
+
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
|
| 353 |
+
assert manifest["telemetry_enabled"] is False
|
| 354 |
+
|
| 355 |
+
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.env"
|
| 356 |
+
state_text = state_path.read_text(encoding="utf-8")
|
| 357 |
+
assert f"PORT={port!r}" in state_text
|
| 358 |
+
|
| 359 |
+
docker_calls = _read_fake_docker_log(env)
|
| 360 |
+
help_call = next(
|
| 361 |
+
call
|
| 362 |
+
for call in docker_calls
|
| 363 |
+
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
|
| 364 |
+
)
|
| 365 |
+
assert "-it" not in help_call
|
| 366 |
+
install_call = next(
|
| 367 |
+
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
|
| 368 |
+
)
|
| 369 |
+
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
|
| 370 |
+
|
| 371 |
+
status_result = _run(
|
| 372 |
+
[str(wrapper), "install", "status", "--profile", "smoke"],
|
| 373 |
+
env=env,
|
| 374 |
+
)
|
| 375 |
+
assert "Status: running" in status_result.stdout
|
| 376 |
+
|
| 377 |
+
_run([str(wrapper), "install", "stop", "--profile", "smoke"], env=env)
|
| 378 |
+
stopped_result = _run(
|
| 379 |
+
[str(wrapper), "install", "status", "--profile", "smoke"],
|
| 380 |
+
env=env,
|
| 381 |
+
)
|
| 382 |
+
assert "Status: stopped" in stopped_result.stdout
|
| 383 |
+
|
| 384 |
+
_run([str(wrapper), "install", "start", "--profile", "smoke"], env=env)
|
| 385 |
+
restarted_result = _run(
|
| 386 |
+
[str(wrapper), "install", "status", "--profile", "smoke"],
|
| 387 |
+
env=env,
|
| 388 |
+
)
|
| 389 |
+
assert "Status: running" in restarted_result.stdout
|
| 390 |
+
|
| 391 |
+
rejected = _run(
|
| 392 |
+
[str(wrapper), "install", "apply", "--scope", "user"],
|
| 393 |
+
env=env,
|
| 394 |
+
check=False,
|
| 395 |
+
)
|
| 396 |
+
assert rejected.returncode != 0
|
| 397 |
+
assert "does not support provider/user/system mutation flags" in rejected.stderr
|
| 398 |
+
|
| 399 |
+
_run([str(wrapper), "install", "restart", "--profile", "smoke"], env=env)
|
| 400 |
+
_run([str(wrapper), "install", "remove", "--profile", "smoke"], env=env)
|
| 401 |
+
assert not manifest_path.parent.exists()
|
| 402 |
+
finally:
|
| 403 |
+
_cleanup_fake_docker(env)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def _powershell_executable() -> str | None:
|
| 407 |
+
return shutil.which("pwsh") or shutil.which("powershell") or shutil.which("powershell.exe")
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@pytest.mark.skipif(
|
| 411 |
+
os.name != "nt" or _powershell_executable() is None,
|
| 412 |
+
reason="Windows PowerShell coverage runs on Windows hosts only",
|
| 413 |
+
)
|
| 414 |
+
def test_powershell_native_installer_supports_persistent_docker_lifecycle(tmp_path: Path) -> None:
|
| 415 |
+
powershell = _powershell_executable()
|
| 416 |
+
assert powershell is not None
|
| 417 |
+
|
| 418 |
+
home = tmp_path / "home"
|
| 419 |
+
(home / ".local").mkdir(parents=True)
|
| 420 |
+
env = _build_env(home, tmp_path)
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
_run(
|
| 424 |
+
[
|
| 425 |
+
powershell,
|
| 426 |
+
"-NoProfile",
|
| 427 |
+
"-ExecutionPolicy",
|
| 428 |
+
"Bypass",
|
| 429 |
+
"-File",
|
| 430 |
+
str(REPO_ROOT / "scripts" / "install.ps1"),
|
| 431 |
+
],
|
| 432 |
+
env=env,
|
| 433 |
+
cwd=REPO_ROOT,
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
wrapper = home / ".local" / "bin" / "headroom.ps1"
|
| 437 |
+
assert wrapper.exists()
|
| 438 |
+
cmd_wrapper = home / ".local" / "bin" / "headroom.cmd"
|
| 439 |
+
assert cmd_wrapper.exists()
|
| 440 |
+
|
| 441 |
+
help_result = _run(
|
| 442 |
+
[
|
| 443 |
+
powershell,
|
| 444 |
+
"-NoProfile",
|
| 445 |
+
"-ExecutionPolicy",
|
| 446 |
+
"Bypass",
|
| 447 |
+
"-File",
|
| 448 |
+
str(wrapper),
|
| 449 |
+
"install",
|
| 450 |
+
"-?",
|
| 451 |
+
],
|
| 452 |
+
env=env,
|
| 453 |
+
)
|
| 454 |
+
_run(
|
| 455 |
+
[
|
| 456 |
+
powershell,
|
| 457 |
+
"-NoProfile",
|
| 458 |
+
"-ExecutionPolicy",
|
| 459 |
+
"Bypass",
|
| 460 |
+
"-File",
|
| 461 |
+
str(wrapper),
|
| 462 |
+
"proxy",
|
| 463 |
+
"--help",
|
| 464 |
+
],
|
| 465 |
+
env=env,
|
| 466 |
+
)
|
| 467 |
+
assert "persistent-docker preset only" in help_result.stdout
|
| 468 |
+
cmd_help_result = _run(
|
| 469 |
+
["cmd.exe", "/c", str(cmd_wrapper), "install", "-?"],
|
| 470 |
+
env=env,
|
| 471 |
+
)
|
| 472 |
+
assert "persistent-docker preset only" in cmd_help_result.stdout
|
| 473 |
+
_run(
|
| 474 |
+
[
|
| 475 |
+
powershell,
|
| 476 |
+
"-NoProfile",
|
| 477 |
+
"-ExecutionPolicy",
|
| 478 |
+
"Bypass",
|
| 479 |
+
"-File",
|
| 480 |
+
str(wrapper),
|
| 481 |
+
"--help",
|
| 482 |
+
],
|
| 483 |
+
env=env,
|
| 484 |
+
)
|
| 485 |
+
invalid_profile = _run(
|
| 486 |
+
["cmd.exe", "/c", str(cmd_wrapper), "install", "status", "--profile", ".."],
|
| 487 |
+
env=env,
|
| 488 |
+
check=False,
|
| 489 |
+
)
|
| 490 |
+
assert invalid_profile.returncode != 0
|
| 491 |
+
assert "Invalid profile name '..'" in invalid_profile.stderr
|
| 492 |
+
missing_profile_value = _run(
|
| 493 |
+
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--profile"],
|
| 494 |
+
env=env,
|
| 495 |
+
check=False,
|
| 496 |
+
)
|
| 497 |
+
assert missing_profile_value.returncode != 0
|
| 498 |
+
assert "Option --profile requires a value" in missing_profile_value.stderr
|
| 499 |
+
missing_proxy_port = _run(
|
| 500 |
+
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port"],
|
| 501 |
+
env=env,
|
| 502 |
+
check=False,
|
| 503 |
+
)
|
| 504 |
+
assert missing_proxy_port.returncode != 0
|
| 505 |
+
assert "Option --port requires a value" in missing_proxy_port.stderr
|
| 506 |
+
invalid_proxy_port = _run(
|
| 507 |
+
["cmd.exe", "/c", str(cmd_wrapper), "proxy", "--port", "abc"],
|
| 508 |
+
env=env,
|
| 509 |
+
check=False,
|
| 510 |
+
)
|
| 511 |
+
assert invalid_proxy_port.returncode != 0
|
| 512 |
+
assert "Invalid port 'abc'" in invalid_proxy_port.stderr
|
| 513 |
+
missing_wrap_port = _run(
|
| 514 |
+
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port"],
|
| 515 |
+
env=env,
|
| 516 |
+
check=False,
|
| 517 |
+
)
|
| 518 |
+
assert missing_wrap_port.returncode != 0
|
| 519 |
+
assert "Option --port requires a value" in missing_wrap_port.stderr
|
| 520 |
+
invalid_wrap_port = _run(
|
| 521 |
+
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "claude", "--port", "abc"],
|
| 522 |
+
env=env,
|
| 523 |
+
check=False,
|
| 524 |
+
)
|
| 525 |
+
assert invalid_wrap_port.returncode != 0
|
| 526 |
+
assert "Invalid port 'abc'" in invalid_wrap_port.stderr
|
| 527 |
+
missing_openclaw_proxy_port = _run(
|
| 528 |
+
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port"],
|
| 529 |
+
env=env,
|
| 530 |
+
check=False,
|
| 531 |
+
)
|
| 532 |
+
assert missing_openclaw_proxy_port.returncode != 0
|
| 533 |
+
assert "Option --proxy-port requires a value" in missing_openclaw_proxy_port.stderr
|
| 534 |
+
invalid_openclaw_proxy_port = _run(
|
| 535 |
+
["cmd.exe", "/c", str(cmd_wrapper), "wrap", "openclaw", "--proxy-port", "abc"],
|
| 536 |
+
env=env,
|
| 537 |
+
check=False,
|
| 538 |
+
)
|
| 539 |
+
assert invalid_openclaw_proxy_port.returncode != 0
|
| 540 |
+
assert "Invalid port 'abc'" in invalid_openclaw_proxy_port.stderr
|
| 541 |
+
for invalid_port in ("abc", "0", "65536"):
|
| 542 |
+
invalid_port_result = _run(
|
| 543 |
+
["cmd.exe", "/c", str(cmd_wrapper), "install", "apply", "--port", invalid_port],
|
| 544 |
+
env=env,
|
| 545 |
+
check=False,
|
| 546 |
+
)
|
| 547 |
+
assert invalid_port_result.returncode != 0
|
| 548 |
+
assert f"Invalid port '{invalid_port}'" in invalid_port_result.stderr
|
| 549 |
+
|
| 550 |
+
port = _free_port()
|
| 551 |
+
_run(
|
| 552 |
+
[
|
| 553 |
+
"cmd.exe",
|
| 554 |
+
"/c",
|
| 555 |
+
str(cmd_wrapper),
|
| 556 |
+
"install",
|
| 557 |
+
"apply",
|
| 558 |
+
"--profile",
|
| 559 |
+
"smoke",
|
| 560 |
+
"--port",
|
| 561 |
+
str(port),
|
| 562 |
+
"--memory",
|
| 563 |
+
"--no-telemetry",
|
| 564 |
+
"--image",
|
| 565 |
+
"fake/headroom:test",
|
| 566 |
+
],
|
| 567 |
+
env=env,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
manifest_path = home / ".headroom" / "deploy" / "smoke" / "manifest.json"
|
| 571 |
+
state_path = home / ".headroom" / "deploy" / "smoke" / "docker-native.json"
|
| 572 |
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 573 |
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
| 574 |
+
assert manifest["preset"] == "persistent-docker"
|
| 575 |
+
assert manifest["port"] == port
|
| 576 |
+
assert manifest["memory_enabled"] is True
|
| 577 |
+
assert manifest["memory_db_path"] == "/tmp/headroom-home/.headroom/memory.db"
|
| 578 |
+
assert manifest["telemetry_enabled"] is False
|
| 579 |
+
assert state["container_name"] == "headroom-smoke"
|
| 580 |
+
|
| 581 |
+
docker_calls = _read_fake_docker_log(env)
|
| 582 |
+
help_call = next(
|
| 583 |
+
call
|
| 584 |
+
for call in docker_calls
|
| 585 |
+
if call[:2] == ["run", "--rm"] and "--entrypoint" in call and "--help" in call
|
| 586 |
+
)
|
| 587 |
+
assert "-it" not in help_call
|
| 588 |
+
proxy_help_call = next(
|
| 589 |
+
call
|
| 590 |
+
for call in docker_calls
|
| 591 |
+
if call[:2] == ["run", "--rm"] and "-p" in call and "proxy" in call and "--help" in call
|
| 592 |
+
)
|
| 593 |
+
assert "-it" not in proxy_help_call
|
| 594 |
+
install_call = next(
|
| 595 |
+
call for call in docker_calls if call[:2] == ["run", "-d"] and "--name" in call
|
| 596 |
+
)
|
| 597 |
+
assert "/tmp/headroom-home/.headroom/memory.db" in install_call
|
| 598 |
+
|
| 599 |
+
status_result = _run(
|
| 600 |
+
[
|
| 601 |
+
powershell,
|
| 602 |
+
"-NoProfile",
|
| 603 |
+
"-ExecutionPolicy",
|
| 604 |
+
"Bypass",
|
| 605 |
+
"-File",
|
| 606 |
+
str(wrapper),
|
| 607 |
+
"install",
|
| 608 |
+
"status",
|
| 609 |
+
"--profile",
|
| 610 |
+
"smoke",
|
| 611 |
+
],
|
| 612 |
+
env=env,
|
| 613 |
+
)
|
| 614 |
+
assert "Status: running" in status_result.stdout
|
| 615 |
+
|
| 616 |
+
_run(
|
| 617 |
+
[
|
| 618 |
+
powershell,
|
| 619 |
+
"-NoProfile",
|
| 620 |
+
"-ExecutionPolicy",
|
| 621 |
+
"Bypass",
|
| 622 |
+
"-File",
|
| 623 |
+
str(wrapper),
|
| 624 |
+
"install",
|
| 625 |
+
"stop",
|
| 626 |
+
"--profile",
|
| 627 |
+
"smoke",
|
| 628 |
+
],
|
| 629 |
+
env=env,
|
| 630 |
+
)
|
| 631 |
+
stopped_result = _run(
|
| 632 |
+
[
|
| 633 |
+
powershell,
|
| 634 |
+
"-NoProfile",
|
| 635 |
+
"-ExecutionPolicy",
|
| 636 |
+
"Bypass",
|
| 637 |
+
"-File",
|
| 638 |
+
str(wrapper),
|
| 639 |
+
"install",
|
| 640 |
+
"status",
|
| 641 |
+
"--profile",
|
| 642 |
+
"smoke",
|
| 643 |
+
],
|
| 644 |
+
env=env,
|
| 645 |
+
)
|
| 646 |
+
assert "Status: stopped" in stopped_result.stdout
|
| 647 |
+
|
| 648 |
+
_run(
|
| 649 |
+
[
|
| 650 |
+
powershell,
|
| 651 |
+
"-NoProfile",
|
| 652 |
+
"-ExecutionPolicy",
|
| 653 |
+
"Bypass",
|
| 654 |
+
"-File",
|
| 655 |
+
str(wrapper),
|
| 656 |
+
"install",
|
| 657 |
+
"start",
|
| 658 |
+
"--profile",
|
| 659 |
+
"smoke",
|
| 660 |
+
],
|
| 661 |
+
env=env,
|
| 662 |
+
)
|
| 663 |
+
started_result = _run(
|
| 664 |
+
[
|
| 665 |
+
powershell,
|
| 666 |
+
"-NoProfile",
|
| 667 |
+
"-ExecutionPolicy",
|
| 668 |
+
"Bypass",
|
| 669 |
+
"-File",
|
| 670 |
+
str(wrapper),
|
| 671 |
+
"install",
|
| 672 |
+
"status",
|
| 673 |
+
"--profile",
|
| 674 |
+
"smoke",
|
| 675 |
+
],
|
| 676 |
+
env=env,
|
| 677 |
+
)
|
| 678 |
+
assert "Status: running" in started_result.stdout
|
| 679 |
+
|
| 680 |
+
rejected = _run(
|
| 681 |
+
[
|
| 682 |
+
powershell,
|
| 683 |
+
"-NoProfile",
|
| 684 |
+
"-ExecutionPolicy",
|
| 685 |
+
"Bypass",
|
| 686 |
+
"-File",
|
| 687 |
+
str(wrapper),
|
| 688 |
+
"install",
|
| 689 |
+
"apply",
|
| 690 |
+
"--scope",
|
| 691 |
+
"user",
|
| 692 |
+
],
|
| 693 |
+
env=env,
|
| 694 |
+
check=False,
|
| 695 |
+
)
|
| 696 |
+
assert rejected.returncode != 0
|
| 697 |
+
assert "does not support provider/user/system mutation flags" in rejected.stderr
|
| 698 |
+
|
| 699 |
+
_run(
|
| 700 |
+
[
|
| 701 |
+
powershell,
|
| 702 |
+
"-NoProfile",
|
| 703 |
+
"-ExecutionPolicy",
|
| 704 |
+
"Bypass",
|
| 705 |
+
"-File",
|
| 706 |
+
str(wrapper),
|
| 707 |
+
"install",
|
| 708 |
+
"restart",
|
| 709 |
+
"--profile",
|
| 710 |
+
"smoke",
|
| 711 |
+
],
|
| 712 |
+
env=env,
|
| 713 |
+
)
|
| 714 |
+
_run(
|
| 715 |
+
[
|
| 716 |
+
powershell,
|
| 717 |
+
"-NoProfile",
|
| 718 |
+
"-ExecutionPolicy",
|
| 719 |
+
"Bypass",
|
| 720 |
+
"-File",
|
| 721 |
+
str(wrapper),
|
| 722 |
+
"install",
|
| 723 |
+
"remove",
|
| 724 |
+
"--profile",
|
| 725 |
+
"smoke",
|
| 726 |
+
],
|
| 727 |
+
env=env,
|
| 728 |
+
)
|
| 729 |
+
assert not manifest_path.parent.exists()
|
| 730 |
+
finally:
|
| 731 |
+
_cleanup_fake_docker(env)
|
tests/test_install/test_supervisors.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from headroom.install.models import DeploymentManifest, SupervisorKind
|
| 6 |
+
from headroom.install.supervisors import (
|
| 7 |
+
_linux_service_unit,
|
| 8 |
+
_linux_task_spec,
|
| 9 |
+
_macos_launchd_plist,
|
| 10 |
+
_render_windows_runner,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _manifest(
|
| 15 |
+
*, profile: str = "default", scope: str = "user", supervisor: str = "service"
|
| 16 |
+
) -> DeploymentManifest:
|
| 17 |
+
return DeploymentManifest(
|
| 18 |
+
profile=profile,
|
| 19 |
+
preset="persistent-service",
|
| 20 |
+
runtime_kind="python",
|
| 21 |
+
supervisor_kind=supervisor,
|
| 22 |
+
scope=scope,
|
| 23 |
+
provider_mode="manual",
|
| 24 |
+
targets=[],
|
| 25 |
+
port=8787,
|
| 26 |
+
host="127.0.0.1",
|
| 27 |
+
backend="anthropic",
|
| 28 |
+
service_name=f"headroom-{profile}",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_linux_service_unit_uses_user_systemd_path(monkeypatch, tmp_path: Path) -> None:
|
| 33 |
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
| 34 |
+
manifest = _manifest()
|
| 35 |
+
|
| 36 |
+
unit_path, content = _linux_service_unit(manifest, tmp_path / "run-headroom.sh")
|
| 37 |
+
|
| 38 |
+
assert unit_path == tmp_path / ".config" / "systemd" / "user" / "headroom-default.service"
|
| 39 |
+
assert "ExecStart=" + str(tmp_path / "run-headroom.sh") in content
|
| 40 |
+
assert "Restart=on-failure" in content
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_linux_task_spec_for_user_scope_includes_crontab_markers(tmp_path: Path) -> None:
|
| 44 |
+
manifest = _manifest(profile="smoke", supervisor=SupervisorKind.TASK.value)
|
| 45 |
+
|
| 46 |
+
cron_path, content = _linux_task_spec(manifest, tmp_path / "ensure-headroom.sh")
|
| 47 |
+
|
| 48 |
+
assert cron_path is None
|
| 49 |
+
assert "# >>> headroom smoke >>>" in content
|
| 50 |
+
assert "# <<< headroom smoke <<<" in content
|
| 51 |
+
assert "@reboot" in content
|
| 52 |
+
assert "*/5 * * * *" in content
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_macos_launchd_plist_switches_between_keepalive_and_interval(
|
| 56 |
+
monkeypatch, tmp_path: Path
|
| 57 |
+
) -> None:
|
| 58 |
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
| 59 |
+
|
| 60 |
+
service_manifest = _manifest(supervisor=SupervisorKind.SERVICE.value)
|
| 61 |
+
service_path, service_content = _macos_launchd_plist(
|
| 62 |
+
service_manifest, tmp_path / "run-headroom.sh"
|
| 63 |
+
)
|
| 64 |
+
assert service_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.default.plist"
|
| 65 |
+
assert "<key>KeepAlive</key>" in service_content
|
| 66 |
+
assert "<key>StartInterval</key>" not in service_content
|
| 67 |
+
|
| 68 |
+
task_manifest = _manifest(profile="tasky", supervisor=SupervisorKind.TASK.value)
|
| 69 |
+
task_path, task_content = _macos_launchd_plist(
|
| 70 |
+
task_manifest, tmp_path / "ensure-headroom.sh", interval=300
|
| 71 |
+
)
|
| 72 |
+
assert task_path == tmp_path / "Library" / "LaunchAgents" / "com.headroom.tasky.plist"
|
| 73 |
+
assert "<key>StartInterval</key>" in task_content
|
| 74 |
+
assert "<integer>300</integer>" in task_content
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_render_windows_runner_writes_ps1_and_cmd_wrappers(tmp_path: Path) -> None:
|
| 78 |
+
ps1_path = tmp_path / "run-headroom.ps1"
|
| 79 |
+
cmd_path = tmp_path / "run-headroom.cmd"
|
| 80 |
+
|
| 81 |
+
records = _render_windows_runner(
|
| 82 |
+
ps1_path,
|
| 83 |
+
cmd_path,
|
| 84 |
+
["C:\\Program Files\\Python\\python.exe", "headroom", "install", "agent", "run"],
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
assert [record.path for record in records] == [str(ps1_path), str(cmd_path)]
|
| 88 |
+
ps1_content = ps1_path.read_text(encoding="utf-8")
|
| 89 |
+
cmd_content = cmd_path.read_text(encoding="utf-8")
|
| 90 |
+
assert '& "C:\\Program Files\\Python\\python.exe" headroom install agent run' in ps1_content
|
| 91 |
+
assert (
|
| 92 |
+
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-headroom.ps1" %*'
|
| 93 |
+
in cmd_content
|
| 94 |
+
)
|