mad-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mad_cli/__init__.py +3 -0
- mad_cli/__main__.py +6 -0
- mad_cli/app.py +77 -0
- mad_cli/commands/__init__.py +5 -0
- mad_cli/commands/_adapt.py +41 -0
- mad_cli/commands/_common.py +12 -0
- mad_cli/commands/config.py +94 -0
- mad_cli/commands/install.py +504 -0
- mad_cli/commands/instances.py +102 -0
- mad_cli/commands/keys.py +126 -0
- mad_cli/commands/lifecycle.py +69 -0
- mad_cli/commands/profiles.py +238 -0
- mad_cli/commands/service.py +220 -0
- mad_cli/commands/versions.py +61 -0
- mad_cli/core/__init__.py +4 -0
- mad_cli/core/claude_creds.py +31 -0
- mad_cli/core/compose.py +145 -0
- mad_cli/core/docker_check.py +89 -0
- mad_cli/core/envfile.py +140 -0
- mad_cli/core/instance.py +110 -0
- mad_cli/core/keyspec.py +98 -0
- mad_cli/core/paths.py +40 -0
- mad_cli/core/profiles.py +93 -0
- mad_cli/core/pypi.py +29 -0
- mad_cli/core/templates.py +91 -0
- mad_cli/core/usecases/__init__.py +11 -0
- mad_cli/core/usecases/adopt.py +55 -0
- mad_cli/core/usecases/configvals.py +94 -0
- mad_cli/core/usecases/errors.py +57 -0
- mad_cli/core/usecases/install.py +263 -0
- mad_cli/core/usecases/instances.py +156 -0
- mad_cli/core/usecases/keys.py +169 -0
- mad_cli/core/usecases/lifecycle.py +76 -0
- mad_cli/core/usecases/service.py +269 -0
- mad_cli/core/usecases/versions.py +126 -0
- mad_cli/py.typed +0 -0
- mad_cli/server/__init__.py +13 -0
- mad_cli/server/app.py +260 -0
- mad_cli/server/auth.py +41 -0
- mad_cli/server/models.py +156 -0
- mad_cli/templates/Dockerfile.tmpl +66 -0
- mad_cli/templates/__init__.py +6 -0
- mad_cli/templates/com.mad-core.mad-cli.plist.tmpl +28 -0
- mad_cli/templates/compose.yml.tmpl +29 -0
- mad_cli/templates/entrypoint.sh.tmpl +11 -0
- mad_cli/templates/mad-cli.service.tmpl +15 -0
- mad_cli/ui/__init__.py +5 -0
- mad_cli/ui/console.py +65 -0
- mad_cli/ui/prompts.py +83 -0
- mad_cli-0.4.0.dist-info/METADATA +167 -0
- mad_cli-0.4.0.dist-info/RECORD +54 -0
- mad_cli-0.4.0.dist-info/WHEEL +4 -0
- mad_cli-0.4.0.dist-info/entry_points.txt +2 -0
- mad_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Service-mode use cases: the API token, the dedicated server venv and the
|
|
2
|
+
systemd unit / launchd plist rendering.
|
|
3
|
+
|
|
4
|
+
The base CLI is a two-dependency package, so ``mad serve`` / ``mad service`` must
|
|
5
|
+
not assume the ``server`` extra is importable. When it is not, ``mad service
|
|
6
|
+
install`` bootstraps a dedicated virtualenv under ``config_root()/server-venv`` and
|
|
7
|
+
installs ``mad-cli[server]`` into it (pinned to the running CLI's version, or from
|
|
8
|
+
a local wheel via ``--wheel``); the rendered unit/plist then points ``ExecStart``
|
|
9
|
+
at ``<venv>/bin/mad serve``. When the extra *is* importable, the current
|
|
10
|
+
interpreter's ``mad`` is used and no venv is created.
|
|
11
|
+
|
|
12
|
+
Everything here is framework-free (no typer / rich / fastapi). Activating the unit
|
|
13
|
+
(``systemctl`` / ``launchctl``) is the caller's business; this module only renders
|
|
14
|
+
files and provisions the runtime.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.util
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import shlex
|
|
23
|
+
import string
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
30
|
+
|
|
31
|
+
from mad_cli import __version__
|
|
32
|
+
from mad_cli.core.paths import config_root
|
|
33
|
+
from mad_cli.core.usecases.errors import PreconditionError
|
|
34
|
+
|
|
35
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
36
|
+
DEFAULT_PORT = 7373
|
|
37
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
38
|
+
|
|
39
|
+
LAUNCHD_LABEL = "com.mad-core.mad-cli"
|
|
40
|
+
SYSTEMD_UNIT_NAME = "mad-cli.service"
|
|
41
|
+
PLIST_NAME = f"{LAUNCHD_LABEL}.plist"
|
|
42
|
+
|
|
43
|
+
_SERVER_MODULES = ("fastapi", "uvicorn")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── API token ────────────────────────────────────────────────────────────────
|
|
47
|
+
def api_token_path() -> Path:
|
|
48
|
+
"""Path of the bearer token file (``config_root()/api-token``)."""
|
|
49
|
+
return config_root() / "api-token"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_api_token() -> str | None:
|
|
53
|
+
"""Return the stored bearer token, or ``None`` when it does not exist."""
|
|
54
|
+
path = api_token_path()
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
return None
|
|
57
|
+
token = path.read_text(encoding="utf-8").strip()
|
|
58
|
+
return token or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ensure_api_token() -> str:
|
|
62
|
+
"""Return the bearer token, generating a fresh 0600 one on first use."""
|
|
63
|
+
existing = read_api_token()
|
|
64
|
+
if existing is not None:
|
|
65
|
+
return existing
|
|
66
|
+
token = secrets.token_urlsafe(32)
|
|
67
|
+
path = api_token_path()
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
path.write_text(token + "\n", encoding="utf-8")
|
|
70
|
+
path.chmod(0o600)
|
|
71
|
+
return token
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── server-extra detection & the dedicated venv ──────────────────────────────
|
|
75
|
+
def server_deps_available() -> bool:
|
|
76
|
+
"""True when the ``server`` extra (fastapi + uvicorn) is importable here."""
|
|
77
|
+
return all(importlib.util.find_spec(mod) is not None for mod in _SERVER_MODULES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def server_venv_dir() -> Path:
|
|
81
|
+
"""The dedicated server virtualenv directory (``config_root()/server-venv``)."""
|
|
82
|
+
return config_root() / "server-venv"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _venv_bin_dir(venv: Path) -> Path:
|
|
86
|
+
return venv / ("Scripts" if os.name == "nt" else "bin")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def server_venv_mad() -> Path:
|
|
90
|
+
"""The ``mad`` console script inside the dedicated server venv."""
|
|
91
|
+
exe = "mad.exe" if os.name == "nt" else "mad"
|
|
92
|
+
return _venv_bin_dir(server_venv_dir()) / exe
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def server_venv_exists() -> bool:
|
|
96
|
+
"""True when the dedicated server venv has a usable ``mad`` binary."""
|
|
97
|
+
return server_venv_mad().exists()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run(cmd: list[str]) -> None:
|
|
101
|
+
"""Run ``cmd``; raise :class:`PreconditionError` with captured output on failure."""
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
raise PreconditionError(f"failed to run {' '.join(cmd)}: {exc}") from exc
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
108
|
+
raise PreconditionError(
|
|
109
|
+
f"`{' '.join(cmd)}` failed (exit {result.returncode})"
|
|
110
|
+
+ (f": {detail}" if detail else "")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def bootstrap_server_venv(*, wheel: Path | None = None) -> Path:
|
|
115
|
+
"""Create ``config_root()/server-venv`` and install ``mad-cli[server]`` into it.
|
|
116
|
+
|
|
117
|
+
From ``wheel`` (a local wheel/sdist, extras appended) when given — no network,
|
|
118
|
+
exact artifact — otherwise ``mad-cli[server]==<running version>`` from PyPI. A
|
|
119
|
+
PyPI failure raises :class:`PreconditionError` hinting at ``--wheel``.
|
|
120
|
+
"""
|
|
121
|
+
venv_dir = server_venv_dir()
|
|
122
|
+
_run([sys.executable, "-m", "venv", str(venv_dir)])
|
|
123
|
+
pip = str(_venv_bin_dir(venv_dir) / ("pip.exe" if os.name == "nt" else "pip"))
|
|
124
|
+
target = f"{wheel}[server]" if wheel is not None else f"mad-cli[server]=={__version__}"
|
|
125
|
+
try:
|
|
126
|
+
_run([pip, "install", target])
|
|
127
|
+
except PreconditionError:
|
|
128
|
+
if wheel is None:
|
|
129
|
+
raise PreconditionError(
|
|
130
|
+
"could not install 'mad-cli[server]' from PyPI into the server venv; "
|
|
131
|
+
"retry with --wheel PATH pointing at a local wheel or sdist"
|
|
132
|
+
) from None
|
|
133
|
+
raise
|
|
134
|
+
return venv_dir
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def server_venv_version() -> str | None:
|
|
138
|
+
"""Return the ``mad_cli`` version installed in the server venv, or ``None``."""
|
|
139
|
+
python = _venv_bin_dir(server_venv_dir()) / ("python.exe" if os.name == "nt" else "python")
|
|
140
|
+
if not python.exists():
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
out = subprocess.run(
|
|
144
|
+
[str(python), "-c", "import mad_cli; print(mad_cli.__version__)"],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
check=False,
|
|
148
|
+
)
|
|
149
|
+
except OSError:
|
|
150
|
+
return None
|
|
151
|
+
if out.returncode != 0:
|
|
152
|
+
return None
|
|
153
|
+
return (out.stdout or "").strip() or None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── launcher resolution ──────────────────────────────────────────────────────
|
|
157
|
+
def _mad_launcher() -> list[str]:
|
|
158
|
+
"""Robustly resolve the current interpreter's ``mad`` launcher argv.
|
|
159
|
+
|
|
160
|
+
Prefers the console script next to ``sys.executable`` (works inside a venv
|
|
161
|
+
regardless of ``PATH``), then ``PATH``, falling back to ``python -m mad_cli``.
|
|
162
|
+
"""
|
|
163
|
+
import shutil
|
|
164
|
+
|
|
165
|
+
candidate = Path(sys.executable).with_name("mad.exe" if os.name == "nt" else "mad")
|
|
166
|
+
if candidate.exists():
|
|
167
|
+
return [str(candidate)]
|
|
168
|
+
found = shutil.which("mad")
|
|
169
|
+
if found:
|
|
170
|
+
return [found]
|
|
171
|
+
return [sys.executable, "-m", "mad_cli"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ensure_server_runtime(*, wheel: Path | None = None) -> tuple[list[str], bool]:
|
|
175
|
+
"""Return ``(mad launcher argv, bootstrapped)`` for a server-capable ``mad``.
|
|
176
|
+
|
|
177
|
+
With ``wheel`` given, or when the ``server`` extra is not importable in the
|
|
178
|
+
current interpreter, a dedicated venv is provisioned and its ``mad`` is
|
|
179
|
+
returned; otherwise the current interpreter's ``mad`` is used unchanged.
|
|
180
|
+
"""
|
|
181
|
+
if wheel is None and server_deps_available():
|
|
182
|
+
return _mad_launcher(), False
|
|
183
|
+
bootstrap_server_venv(wheel=wheel)
|
|
184
|
+
return [str(server_venv_mad())], True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def serve_argv(launcher: list[str], host: str, port: int) -> list[str]:
|
|
188
|
+
"""Build the ``mad serve`` argv from a launcher and bind address."""
|
|
189
|
+
return [*launcher, "serve", "--host", host, "--port", str(port)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def is_loopback(host: str) -> bool:
|
|
193
|
+
"""True when ``host`` binds only the loopback interface."""
|
|
194
|
+
return host in LOOPBACK_HOSTS
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── unit / plist rendering ───────────────────────────────────────────────────
|
|
198
|
+
def _load_template(name: str) -> string.Template:
|
|
199
|
+
text = resources.files("mad_cli.templates").joinpath(name).read_text(encoding="utf-8")
|
|
200
|
+
return string.Template(text)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def render_systemd_unit(*, exec_args: list[str], config_dir: Path) -> str:
|
|
204
|
+
"""Render the systemd **user** unit. ``ExecStart`` is a single command line."""
|
|
205
|
+
return _load_template("mad-cli.service.tmpl").substitute(
|
|
206
|
+
exec_start=shlex.join(exec_args),
|
|
207
|
+
config_dir=str(config_dir),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_launchd_plist(*, exec_args: list[str], config_dir: Path, log_path: Path) -> str:
|
|
212
|
+
"""Render the launchd LaunchAgent plist (``ProgramArguments`` as a string array)."""
|
|
213
|
+
program_arguments = "\n".join(
|
|
214
|
+
f" <string>{xml_escape(arg)}</string>" for arg in exec_args
|
|
215
|
+
)
|
|
216
|
+
return _load_template(f"{PLIST_NAME}.tmpl").substitute(
|
|
217
|
+
label=LAUNCHD_LABEL,
|
|
218
|
+
program_arguments=program_arguments,
|
|
219
|
+
config_dir=str(config_dir),
|
|
220
|
+
log_path=str(log_path),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def systemd_unit_path() -> Path:
|
|
225
|
+
"""Default install path of the systemd user unit."""
|
|
226
|
+
return Path.home() / ".config" / "systemd" / "user" / SYSTEMD_UNIT_NAME
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def launchd_plist_path() -> Path:
|
|
230
|
+
"""Default install path of the launchd LaunchAgent plist."""
|
|
231
|
+
return Path.home() / "Library" / "LaunchAgents" / PLIST_NAME
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def default_log_path() -> Path:
|
|
235
|
+
"""Default log file for the launchd service."""
|
|
236
|
+
return config_root() / "mad-cli.serve.log"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class RenderedService:
|
|
241
|
+
platform: str # "linux" | "darwin"
|
|
242
|
+
default_path: Path
|
|
243
|
+
content: str
|
|
244
|
+
exec_args: list[str]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_service(
|
|
248
|
+
*,
|
|
249
|
+
platform: str,
|
|
250
|
+
exec_args: list[str],
|
|
251
|
+
config_dir: Path,
|
|
252
|
+
) -> RenderedService:
|
|
253
|
+
"""Render the platform's service file and report its default install path.
|
|
254
|
+
|
|
255
|
+
Raises :class:`PreconditionError` on an unsupported platform (only Linux
|
|
256
|
+
systemd-user and macOS launchd are supported).
|
|
257
|
+
"""
|
|
258
|
+
if platform == "linux":
|
|
259
|
+
content = render_systemd_unit(exec_args=exec_args, config_dir=config_dir)
|
|
260
|
+
return RenderedService("linux", systemd_unit_path(), content, exec_args)
|
|
261
|
+
if platform == "darwin":
|
|
262
|
+
content = render_launchd_plist(
|
|
263
|
+
exec_args=exec_args, config_dir=config_dir, log_path=default_log_path()
|
|
264
|
+
)
|
|
265
|
+
return RenderedService("darwin", launchd_plist_path(), content, exec_args)
|
|
266
|
+
raise PreconditionError(
|
|
267
|
+
f"unsupported platform {platform!r}: `mad service` supports Linux (systemd) and macOS "
|
|
268
|
+
"(launchd) only. Use `mad serve` to run in the foreground."
|
|
269
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Version-reporting and update use cases.
|
|
2
|
+
|
|
3
|
+
``versions`` reports, per instance, the pinned / installed / latest-on-PyPI
|
|
4
|
+
versions and whether an update is available. ``update`` re-pins ``MAD_VERSION``
|
|
5
|
+
and rebuilds from scratch (synchronous in the MVP). Shared by ``mad versions`` /
|
|
6
|
+
``mad update`` and the ``/v1/instances/{name}/versions`` + ``/update`` routes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from mad_cli.core import pypi
|
|
14
|
+
from mad_cli.core.compose import ComposeError, ComposeRunner
|
|
15
|
+
from mad_cli.core.instance import Instance, InstanceNotFoundError, discover_instances, get_instance
|
|
16
|
+
from mad_cli.core.templates import EDGE_PACKAGE
|
|
17
|
+
from mad_cli.core.usecases.errors import NotFoundError
|
|
18
|
+
|
|
19
|
+
NOT_RUNNING = "not running"
|
|
20
|
+
UNKNOWN = "?"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _edge_package(instance: Instance) -> str:
|
|
24
|
+
"""The PyPI package to check: ``MAD_EDGE_PACKAGE`` from the .env, else default."""
|
|
25
|
+
return instance.env.get("MAD_EDGE_PACKAGE") or EDGE_PACKAGE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _installed_version(instance: Instance) -> str:
|
|
29
|
+
"""Version reported by ``mad`` inside the container, or ``"not running"``."""
|
|
30
|
+
try:
|
|
31
|
+
out = ComposeRunner(instance).exec(["python", "-c", "import mad; print(mad.__version__)"])
|
|
32
|
+
except ComposeError:
|
|
33
|
+
return NOT_RUNNING
|
|
34
|
+
return out.strip() or NOT_RUNNING
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
38
|
+
"""Best-effort ordering tuple: each dotted segment's leading digit run."""
|
|
39
|
+
parts: list[int] = []
|
|
40
|
+
for chunk in version.split("."):
|
|
41
|
+
digits = ""
|
|
42
|
+
for char in chunk:
|
|
43
|
+
if char.isdigit():
|
|
44
|
+
digits += char
|
|
45
|
+
else:
|
|
46
|
+
break
|
|
47
|
+
parts.append(int(digits) if digits else 0)
|
|
48
|
+
return tuple(parts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _update_status(installed: str, latest: str | None) -> str:
|
|
52
|
+
if latest is None or installed in (NOT_RUNNING, UNKNOWN):
|
|
53
|
+
return UNKNOWN
|
|
54
|
+
if _version_tuple(installed) >= _version_tuple(latest):
|
|
55
|
+
return "up to date"
|
|
56
|
+
return "update available"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class VersionRow:
|
|
61
|
+
name: str
|
|
62
|
+
legacy: bool
|
|
63
|
+
pinned: str # the pin, or "latest"
|
|
64
|
+
installed: str
|
|
65
|
+
latest: str # latest on PyPI, or "?"
|
|
66
|
+
update: str # up to date / update available / ?
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def versions(name: str | None) -> list[VersionRow]:
|
|
70
|
+
"""Report version state for ``name`` (or every instance when ``None``).
|
|
71
|
+
|
|
72
|
+
Raises :class:`NotFoundError` for a named instance that does not exist; an
|
|
73
|
+
empty list means no instances are configured.
|
|
74
|
+
"""
|
|
75
|
+
if name is not None:
|
|
76
|
+
try:
|
|
77
|
+
targets = [get_instance(name)]
|
|
78
|
+
except InstanceNotFoundError as exc:
|
|
79
|
+
raise NotFoundError(f"instance {name!r} not found") from exc
|
|
80
|
+
else:
|
|
81
|
+
targets = discover_instances()
|
|
82
|
+
|
|
83
|
+
rows: list[VersionRow] = []
|
|
84
|
+
latest_cache: dict[str, str | None] = {}
|
|
85
|
+
for inst in targets:
|
|
86
|
+
installed = _installed_version(inst)
|
|
87
|
+
package = _edge_package(inst)
|
|
88
|
+
if package not in latest_cache:
|
|
89
|
+
latest_cache[package] = pypi.latest_version(package)
|
|
90
|
+
latest = latest_cache[package]
|
|
91
|
+
rows.append(
|
|
92
|
+
VersionRow(
|
|
93
|
+
name=inst.name,
|
|
94
|
+
legacy=bool(getattr(inst, "legacy", False)),
|
|
95
|
+
pinned=inst.version_pin or "latest",
|
|
96
|
+
installed=installed,
|
|
97
|
+
latest=latest if latest is not None else UNKNOWN,
|
|
98
|
+
update=_update_status(installed, latest),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class UpdateResult:
|
|
106
|
+
instance: Instance
|
|
107
|
+
target: str # the pin applied, or "latest"
|
|
108
|
+
healthy: bool
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update(instance: Instance, version: str | None) -> UpdateResult:
|
|
112
|
+
"""Re-pin ``MAD_VERSION`` and rebuild the instance from scratch.
|
|
113
|
+
|
|
114
|
+
The returned ``healthy`` flag lets the adapter decide how to signal a
|
|
115
|
+
not-yet-healthy rebuild (the CLI exits non-zero; the API reports it in the
|
|
116
|
+
body).
|
|
117
|
+
"""
|
|
118
|
+
pin = version or ""
|
|
119
|
+
instance.env.set("MAD_VERSION", pin)
|
|
120
|
+
instance.env.save(instance.env_file)
|
|
121
|
+
|
|
122
|
+
runner = ComposeRunner(instance)
|
|
123
|
+
runner.build(no_cache=True)
|
|
124
|
+
runner.up()
|
|
125
|
+
healthy = runner.wait_healthy()
|
|
126
|
+
return UpdateResult(instance=instance, target=pin or "latest", healthy=healthy)
|
mad_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The optional local HTTP API (the ``server`` extra).
|
|
2
|
+
|
|
3
|
+
Nothing here is imported by the base CLI (``mad_cli.app``); it is reached only via
|
|
4
|
+
``mad serve`` behind an import guard, so ``pip install mad-cli`` never pulls in
|
|
5
|
+
FastAPI/uvicorn. The routes are thin adapters over :mod:`mad_cli.core.usecases`,
|
|
6
|
+
exactly like the Typer commands, so the CLI and the API can never drift.
|
|
7
|
+
|
|
8
|
+
See :func:`mad_cli.server.app.create_app` for the FastAPI factory.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from mad_cli.server.app import create_app
|
|
12
|
+
|
|
13
|
+
__all__ = ["create_app"]
|
mad_cli/server/app.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""FastAPI application factory for the v1 local API.
|
|
2
|
+
|
|
3
|
+
Every ``/v1`` route is a thin adapter over :mod:`mad_cli.core.usecases` — the same
|
|
4
|
+
functions the Typer commands call — guarded by the bearer-token dependency. The
|
|
5
|
+
use-case error vocabulary is mapped to HTTP status codes by a single exception
|
|
6
|
+
handler, so the routes stay declarative.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, FastAPI, Request
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from mad_cli import __version__
|
|
18
|
+
from mad_cli.core.envfile import EnvFile
|
|
19
|
+
from mad_cli.core.instance import Instance, InstanceNotFoundError, get_instance
|
|
20
|
+
from mad_cli.core.templates import EDGE_PACKAGE
|
|
21
|
+
from mad_cli.core.usecases import adopt as uc_adopt
|
|
22
|
+
from mad_cli.core.usecases import configvals as uc_config
|
|
23
|
+
from mad_cli.core.usecases import install as uc_install
|
|
24
|
+
from mad_cli.core.usecases import instances as uc_instances
|
|
25
|
+
from mad_cli.core.usecases import keys as uc_keys
|
|
26
|
+
from mad_cli.core.usecases import lifecycle as uc_lifecycle
|
|
27
|
+
from mad_cli.core.usecases import versions as uc_versions
|
|
28
|
+
from mad_cli.core.usecases.errors import NotFoundError, UseCaseError, http_status_for
|
|
29
|
+
from mad_cli.server import models as m
|
|
30
|
+
from mad_cli.server.auth import require_token
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _host_id(getter_name: str) -> int:
|
|
34
|
+
getter = getattr(os, getter_name, None)
|
|
35
|
+
return getter() if getter is not None else 1000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _instance(name: str) -> Instance:
|
|
39
|
+
"""Resolve a path ``{name}`` to an :class:`Instance` or raise 404 via the handler."""
|
|
40
|
+
try:
|
|
41
|
+
return get_instance(name)
|
|
42
|
+
except InstanceNotFoundError:
|
|
43
|
+
raise NotFoundError(f"instance {name!r} not found") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_router() -> APIRouter:
|
|
47
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_token)])
|
|
48
|
+
|
|
49
|
+
# ── instances ────────────────────────────────────────────────────────────
|
|
50
|
+
@router.get("/instances", response_model=list[m.InstanceSummaryModel], tags=["instances"])
|
|
51
|
+
def list_instances() -> list[m.InstanceSummaryModel]:
|
|
52
|
+
return [
|
|
53
|
+
m.InstanceSummaryModel(
|
|
54
|
+
name=row.name,
|
|
55
|
+
legacy=row.legacy,
|
|
56
|
+
port=row.port,
|
|
57
|
+
state=row.state,
|
|
58
|
+
health=row.health,
|
|
59
|
+
version=row.version,
|
|
60
|
+
)
|
|
61
|
+
for row in uc_instances.list_instances()
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
@router.post(
|
|
65
|
+
"/instances",
|
|
66
|
+
response_model=m.InstallResponse,
|
|
67
|
+
status_code=201,
|
|
68
|
+
tags=["instances"],
|
|
69
|
+
)
|
|
70
|
+
def install_instance(req: m.InstallRequest) -> m.InstallResponse:
|
|
71
|
+
scratch = EnvFile.empty()
|
|
72
|
+
for ident, value in req.extra_keys.items():
|
|
73
|
+
uc_install.apply_extra_key(scratch, ident, value)
|
|
74
|
+
extra_env = {var: scratch.get(var) or "" for var in scratch.keys()} # noqa: SIM118
|
|
75
|
+
params = uc_install.InstallParams(
|
|
76
|
+
name=req.name,
|
|
77
|
+
port=req.port,
|
|
78
|
+
data_path=Path(req.data_path),
|
|
79
|
+
timeout_s=req.timeout_s,
|
|
80
|
+
github_token=req.github_token,
|
|
81
|
+
puid=_host_id("getuid"),
|
|
82
|
+
pgid=_host_id("getgid"),
|
|
83
|
+
git_name=req.git_name,
|
|
84
|
+
git_email=req.git_email,
|
|
85
|
+
claude_token=req.claude_token,
|
|
86
|
+
anthropic_api_key=req.anthropic_api_key,
|
|
87
|
+
extra_env=extra_env,
|
|
88
|
+
retention_days=req.retention_days,
|
|
89
|
+
mcp_allowed_hosts=req.mcp_allowed_hosts,
|
|
90
|
+
edge_package=req.edge_package or EDGE_PACKAGE,
|
|
91
|
+
edge_version=req.edge_version,
|
|
92
|
+
start=req.start,
|
|
93
|
+
)
|
|
94
|
+
result = uc_install.install(params)
|
|
95
|
+
return m.InstallResponse(
|
|
96
|
+
name=result.name,
|
|
97
|
+
config_dir=str(result.config_dir),
|
|
98
|
+
data_dir=str(result.data_dir),
|
|
99
|
+
port=result.port,
|
|
100
|
+
started=result.started,
|
|
101
|
+
healthy=result.healthy,
|
|
102
|
+
url=result.url,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@router.get("/instances/{name}", response_model=m.InstanceInfoModel, tags=["instances"])
|
|
106
|
+
def instance_info(name: str) -> m.InstanceInfoModel:
|
|
107
|
+
info = uc_instances.instance_info(name)
|
|
108
|
+
return m.InstanceInfoModel(
|
|
109
|
+
name=info.name,
|
|
110
|
+
legacy=info.legacy,
|
|
111
|
+
config_dir=str(info.config_dir),
|
|
112
|
+
compose_file=str(info.compose_file),
|
|
113
|
+
data_path=str(info.data_path) if info.data_path else None,
|
|
114
|
+
port=info.port,
|
|
115
|
+
version=info.version,
|
|
116
|
+
env=[m.EnvItemModel(key=i.key, value=i.display(), secret=i.secret) for i in info.env],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ── lifecycle ─────────────────────────────────────────────────────────────
|
|
120
|
+
@router.post("/instances/{name}/start", response_model=m.StartResponse, tags=["lifecycle"])
|
|
121
|
+
def start(name: str) -> m.StartResponse:
|
|
122
|
+
inst = _instance(name)
|
|
123
|
+
res = uc_lifecycle.start(inst)
|
|
124
|
+
return m.StartResponse(name=inst.name, healthy=res.healthy, url=res.url)
|
|
125
|
+
|
|
126
|
+
@router.post("/instances/{name}/stop", response_model=m.ActionResponse, tags=["lifecycle"])
|
|
127
|
+
def stop(name: str) -> m.ActionResponse:
|
|
128
|
+
inst = _instance(name)
|
|
129
|
+
uc_lifecycle.stop(inst)
|
|
130
|
+
return m.ActionResponse(name=inst.name, action="stop")
|
|
131
|
+
|
|
132
|
+
@router.post("/instances/{name}/restart", response_model=m.ActionResponse, tags=["lifecycle"])
|
|
133
|
+
def restart(name: str) -> m.ActionResponse:
|
|
134
|
+
inst = _instance(name)
|
|
135
|
+
uc_lifecycle.restart(inst)
|
|
136
|
+
return m.ActionResponse(name=inst.name, action="restart")
|
|
137
|
+
|
|
138
|
+
@router.get("/instances/{name}/status", response_model=m.StatusResponse, tags=["lifecycle"])
|
|
139
|
+
def status(name: str) -> m.StatusResponse:
|
|
140
|
+
inst = _instance(name)
|
|
141
|
+
res = uc_lifecycle.status(inst)
|
|
142
|
+
return m.StatusResponse(name=inst.name, health=res.health, url=res.url, ps=res.ps_text)
|
|
143
|
+
|
|
144
|
+
# ── config ────────────────────────────────────────────────────────────────
|
|
145
|
+
@router.get("/instances/{name}/config", response_model=list[m.EnvItemModel], tags=["config"])
|
|
146
|
+
def get_config(name: str) -> list[m.EnvItemModel]:
|
|
147
|
+
inst = _instance(name)
|
|
148
|
+
return [
|
|
149
|
+
m.EnvItemModel(key=i.key, value=i.display(), secret=i.secret)
|
|
150
|
+
for i in uc_config.list_config(inst)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
@router.put(
|
|
154
|
+
"/instances/{name}/config/{key}", response_model=m.SetConfigResponse, tags=["config"]
|
|
155
|
+
)
|
|
156
|
+
def set_config(name: str, key: str, body: m.SetConfigRequest) -> m.SetConfigResponse:
|
|
157
|
+
inst = _instance(name)
|
|
158
|
+
item, compose_baked = uc_config.set_config(inst, key, body.value)
|
|
159
|
+
return m.SetConfigResponse(
|
|
160
|
+
key=item.key, value=item.display(), secret=item.secret, compose_baked=compose_baked
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@router.delete(
|
|
164
|
+
"/instances/{name}/config/{key}", response_model=m.ActionResponse, tags=["config"]
|
|
165
|
+
)
|
|
166
|
+
def unset_config(name: str, key: str) -> m.ActionResponse:
|
|
167
|
+
inst = _instance(name)
|
|
168
|
+
existed = uc_config.unset_config(inst, key)
|
|
169
|
+
if not existed:
|
|
170
|
+
raise NotFoundError(f"{key} is not set on {inst.name}")
|
|
171
|
+
return m.ActionResponse(name=inst.name, action=f"unset {key}")
|
|
172
|
+
|
|
173
|
+
# ── keys ──────────────────────────────────────────────────────────────────
|
|
174
|
+
@router.get("/instances/{name}/keys", response_model=m.KeysResponse, tags=["keys"])
|
|
175
|
+
def list_keys(name: str) -> m.KeysResponse:
|
|
176
|
+
inst = _instance(name)
|
|
177
|
+
view = uc_keys.list_keys(inst)
|
|
178
|
+
return m.KeysResponse(
|
|
179
|
+
builtins=[
|
|
180
|
+
m.BuiltinKeyModel(
|
|
181
|
+
id=s.id, env_vars=list(s.env_vars), is_set=s.is_set, value=s.masked
|
|
182
|
+
)
|
|
183
|
+
for s in view.builtins
|
|
184
|
+
],
|
|
185
|
+
custom=[m.CustomSecretModel(key=c.key, value=c.masked) for c in view.custom],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@router.put("/instances/{name}/keys/{key_id}", response_model=m.SetKeyResponse, tags=["keys"])
|
|
189
|
+
def set_key(name: str, key_id: str, body: m.SetKeyRequest) -> m.SetKeyResponse:
|
|
190
|
+
inst = _instance(name)
|
|
191
|
+
res = uc_keys.set_key(inst, key_id, body.value)
|
|
192
|
+
return m.SetKeyResponse(
|
|
193
|
+
id=res.id,
|
|
194
|
+
env_vars=list(res.env_vars),
|
|
195
|
+
builtin=res.builtin,
|
|
196
|
+
credentials_written=res.credentials_path is not None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@router.delete(
|
|
200
|
+
"/instances/{name}/keys/{key_id}", response_model=m.ActionResponse, tags=["keys"]
|
|
201
|
+
)
|
|
202
|
+
def remove_key(name: str, key_id: str) -> m.ActionResponse:
|
|
203
|
+
inst = _instance(name)
|
|
204
|
+
res = uc_keys.remove_key(inst, key_id)
|
|
205
|
+
if not res.existed:
|
|
206
|
+
raise NotFoundError(f"{res.id} is not set on {inst.name}")
|
|
207
|
+
return m.ActionResponse(name=inst.name, action=f"remove {res.id}")
|
|
208
|
+
|
|
209
|
+
# ── versions ──────────────────────────────────────────────────────────────
|
|
210
|
+
@router.get("/instances/{name}/versions", response_model=m.VersionRowModel, tags=["versions"])
|
|
211
|
+
def versions(name: str) -> m.VersionRowModel:
|
|
212
|
+
rows = uc_versions.versions(name)
|
|
213
|
+
row = rows[0]
|
|
214
|
+
return m.VersionRowModel(
|
|
215
|
+
name=row.name,
|
|
216
|
+
legacy=row.legacy,
|
|
217
|
+
pinned=row.pinned,
|
|
218
|
+
installed=row.installed,
|
|
219
|
+
latest=row.latest,
|
|
220
|
+
update=row.update,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@router.post("/instances/{name}/update", response_model=m.UpdateResponse, tags=["versions"])
|
|
224
|
+
def update(name: str, body: m.UpdateRequest) -> m.UpdateResponse:
|
|
225
|
+
inst = _instance(name)
|
|
226
|
+
res = uc_versions.update(inst, body.version)
|
|
227
|
+
return m.UpdateResponse(name=inst.name, target=res.target, healthy=res.healthy)
|
|
228
|
+
|
|
229
|
+
# ── adopt ─────────────────────────────────────────────────────────────────
|
|
230
|
+
@router.post("/adopt", response_model=m.AdoptResponse, tags=["adopt"])
|
|
231
|
+
def adopt() -> m.AdoptResponse:
|
|
232
|
+
plan = uc_adopt.plan_adopt()
|
|
233
|
+
if plan is None:
|
|
234
|
+
return m.AdoptResponse(adopted=False)
|
|
235
|
+
uc_adopt.apply_adopt(plan)
|
|
236
|
+
return m.AdoptResponse(
|
|
237
|
+
adopted=True, name=plan.name, target=str(plan.target), moved=plan.movable
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return router
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def create_app() -> FastAPI:
|
|
244
|
+
"""Build the FastAPI application (the OpenAPI schema is generated from it)."""
|
|
245
|
+
app = FastAPI(
|
|
246
|
+
title="mad-cli local API",
|
|
247
|
+
version=__version__,
|
|
248
|
+
summary="Operator API mirroring the mad CLI (instances, lifecycle, config, keys).",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@app.exception_handler(UseCaseError)
|
|
252
|
+
async def _handle_usecase_error(request: Request, exc: UseCaseError) -> JSONResponse:
|
|
253
|
+
return JSONResponse(status_code=http_status_for(exc), content={"detail": str(exc)})
|
|
254
|
+
|
|
255
|
+
@app.get("/health", response_model=m.HealthResponse, tags=["meta"])
|
|
256
|
+
def health() -> m.HealthResponse:
|
|
257
|
+
return m.HealthResponse(status="ok", version=__version__)
|
|
258
|
+
|
|
259
|
+
app.include_router(_build_router())
|
|
260
|
+
return app
|