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.
Files changed (54) hide show
  1. mad_cli/__init__.py +3 -0
  2. mad_cli/__main__.py +6 -0
  3. mad_cli/app.py +77 -0
  4. mad_cli/commands/__init__.py +5 -0
  5. mad_cli/commands/_adapt.py +41 -0
  6. mad_cli/commands/_common.py +12 -0
  7. mad_cli/commands/config.py +94 -0
  8. mad_cli/commands/install.py +504 -0
  9. mad_cli/commands/instances.py +102 -0
  10. mad_cli/commands/keys.py +126 -0
  11. mad_cli/commands/lifecycle.py +69 -0
  12. mad_cli/commands/profiles.py +238 -0
  13. mad_cli/commands/service.py +220 -0
  14. mad_cli/commands/versions.py +61 -0
  15. mad_cli/core/__init__.py +4 -0
  16. mad_cli/core/claude_creds.py +31 -0
  17. mad_cli/core/compose.py +145 -0
  18. mad_cli/core/docker_check.py +89 -0
  19. mad_cli/core/envfile.py +140 -0
  20. mad_cli/core/instance.py +110 -0
  21. mad_cli/core/keyspec.py +98 -0
  22. mad_cli/core/paths.py +40 -0
  23. mad_cli/core/profiles.py +93 -0
  24. mad_cli/core/pypi.py +29 -0
  25. mad_cli/core/templates.py +91 -0
  26. mad_cli/core/usecases/__init__.py +11 -0
  27. mad_cli/core/usecases/adopt.py +55 -0
  28. mad_cli/core/usecases/configvals.py +94 -0
  29. mad_cli/core/usecases/errors.py +57 -0
  30. mad_cli/core/usecases/install.py +263 -0
  31. mad_cli/core/usecases/instances.py +156 -0
  32. mad_cli/core/usecases/keys.py +169 -0
  33. mad_cli/core/usecases/lifecycle.py +76 -0
  34. mad_cli/core/usecases/service.py +269 -0
  35. mad_cli/core/usecases/versions.py +126 -0
  36. mad_cli/py.typed +0 -0
  37. mad_cli/server/__init__.py +13 -0
  38. mad_cli/server/app.py +260 -0
  39. mad_cli/server/auth.py +41 -0
  40. mad_cli/server/models.py +156 -0
  41. mad_cli/templates/Dockerfile.tmpl +66 -0
  42. mad_cli/templates/__init__.py +6 -0
  43. mad_cli/templates/com.mad-core.mad-cli.plist.tmpl +28 -0
  44. mad_cli/templates/compose.yml.tmpl +29 -0
  45. mad_cli/templates/entrypoint.sh.tmpl +11 -0
  46. mad_cli/templates/mad-cli.service.tmpl +15 -0
  47. mad_cli/ui/__init__.py +5 -0
  48. mad_cli/ui/console.py +65 -0
  49. mad_cli/ui/prompts.py +83 -0
  50. mad_cli-0.4.0.dist-info/METADATA +167 -0
  51. mad_cli-0.4.0.dist-info/RECORD +54 -0
  52. mad_cli-0.4.0.dist-info/WHEEL +4 -0
  53. mad_cli-0.4.0.dist-info/entry_points.txt +2 -0
  54. mad_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
mad_cli/core/paths.py ADDED
@@ -0,0 +1,40 @@
1
+ """Config-directory resolution for mad-cli.
2
+
3
+ All paths derive from :func:`config_root`, which honours the ``MAD_CLI_CONFIG_DIR``
4
+ environment override and otherwise falls back to ``~/.config/mad``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+
13
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
14
+
15
+
16
+ def config_root() -> Path:
17
+ """Return the mad-cli config root.
18
+
19
+ ``$MAD_CLI_CONFIG_DIR`` overrides the default ``~/.config/mad``.
20
+ """
21
+ override = os.environ.get("MAD_CLI_CONFIG_DIR")
22
+ if override:
23
+ return Path(override).expanduser()
24
+ return Path.home() / ".config" / "mad"
25
+
26
+
27
+ def instances_root() -> Path:
28
+ """Return the directory that holds one sub-directory per instance."""
29
+ return config_root() / "instances"
30
+
31
+
32
+ def instance_dir(name: str) -> Path:
33
+ """Return the config directory for ``name``.
34
+
35
+ The name must match ``[a-z0-9][a-z0-9-]*`` so it is safe to use as a
36
+ directory name, a Docker Compose project suffix and a container name.
37
+ """
38
+ if not _NAME_RE.match(name):
39
+ raise ValueError(f"invalid instance name {name!r}: must match [a-z0-9][a-z0-9-]*")
40
+ return instances_root() / name
@@ -0,0 +1,93 @@
1
+ """Named environment profiles — reusable sets of ``.env`` values.
2
+
3
+ A *profile* is a named, reusable set of environment variables an operator can
4
+ stamp onto instances. It carries **credentials and tuning**, never an instance's
5
+ identity: the keys that name one specific instance (its port, data path, name,
6
+ uid/gid and version pin — :data:`IDENTITY_KEYS`) are deliberately excluded, so a
7
+ profile can be applied across many instances without collision.
8
+
9
+ Profiles are stored one file per profile at ``config_root()/profiles/<name>.env``
10
+ in the same :class:`~mad_cli.core.envfile.EnvFile` format as an instance's
11
+ ``.env``, ``chmod 600`` because they may hold secrets. The name follows the same
12
+ rule as an instance name (``[a-z0-9][a-z0-9-]*``) so it is safe as a filename.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from mad_cli.core.envfile import EnvFile
21
+ from mad_cli.core.paths import config_root
22
+
23
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
24
+
25
+ # Instance-identity keys a profile must never carry: each names one specific
26
+ # instance, not the reusable credentials/tuning a profile is for. Excluded when
27
+ # seeding a profile from an instance (`mad profiles create --from-instance`).
28
+ IDENTITY_KEYS: tuple[str, ...] = (
29
+ "MAD_INSTANCE",
30
+ "MAD_HOST_PORT",
31
+ "PUID",
32
+ "PGID",
33
+ "MAD_DATA_PATH",
34
+ "MAD_VERSION",
35
+ )
36
+
37
+
38
+ class ProfileNotFoundError(Exception):
39
+ """Raised when a named profile does not exist under the config root."""
40
+
41
+
42
+ def profiles_root() -> Path:
43
+ """Return the directory that holds one ``<name>.env`` file per profile."""
44
+ return config_root() / "profiles"
45
+
46
+
47
+ def _validate_name(name: str) -> str:
48
+ """Return ``name`` if it is a valid profile name, else raise ``ValueError``."""
49
+ if not _NAME_RE.match(name):
50
+ raise ValueError(f"invalid profile name {name!r}: must match [a-z0-9][a-z0-9-]*")
51
+ return name
52
+
53
+
54
+ def profile_path(name: str) -> Path:
55
+ """Return the storage path for profile ``name`` (validates the name)."""
56
+ return profiles_root() / f"{_validate_name(name)}.env"
57
+
58
+
59
+ def list_profiles() -> list[str]:
60
+ """Return every stored profile name, sorted (empty when none exist)."""
61
+ root = profiles_root()
62
+ if not root.is_dir():
63
+ return []
64
+ return sorted(p.stem for p in root.iterdir() if p.is_file() and p.suffix == ".env")
65
+
66
+
67
+ def load_profile(name: str) -> EnvFile:
68
+ """Load profile ``name`` or raise :class:`ProfileNotFoundError`."""
69
+ path = profile_path(name)
70
+ if not path.is_file():
71
+ raise ProfileNotFoundError(name)
72
+ return EnvFile.load(path)
73
+
74
+
75
+ def save_profile(name: str, env: EnvFile) -> Path:
76
+ """Write ``env`` to profile ``name`` (``chmod 600``); return its path.
77
+
78
+ The ``profiles/`` directory is created if needed. The file may hold secrets,
79
+ so it is written with owner-only permissions.
80
+ """
81
+ path = profile_path(name)
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ env.save(path)
84
+ path.chmod(0o600)
85
+ return path
86
+
87
+
88
+ def delete_profile(name: str) -> None:
89
+ """Delete profile ``name`` or raise :class:`ProfileNotFoundError`."""
90
+ path = profile_path(name)
91
+ if not path.is_file():
92
+ raise ProfileNotFoundError(name)
93
+ path.unlink()
mad_cli/core/pypi.py ADDED
@@ -0,0 +1,29 @@
1
+ """Latest-version lookup via the PyPI JSON API (urllib, stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+
8
+ _PYPI_JSON = "https://pypi.org/pypi/{package}/json"
9
+
10
+
11
+ def latest_version(package: str, timeout_s: float = 5.0) -> str | None:
12
+ """Return the latest published version of ``package`` on PyPI, or ``None``.
13
+
14
+ Any failure — unknown package (404), network error, timeout or malformed
15
+ payload — yields ``None`` rather than raising, so callers can degrade
16
+ gracefully when offline.
17
+ """
18
+ url = _PYPI_JSON.format(package=package)
19
+ try:
20
+ with urllib.request.urlopen(url, timeout=timeout_s) as response:
21
+ raw = response.read()
22
+ data = json.loads(raw)
23
+ except (OSError, ValueError):
24
+ return None
25
+ info = data.get("info") if isinstance(data, dict) else None
26
+ if not isinstance(info, dict):
27
+ return None
28
+ version = info.get("version")
29
+ return version if isinstance(version, str) else None
@@ -0,0 +1,91 @@
1
+ """Instance file rendering (Dockerfile / compose.yml / entrypoint.sh / .env).
2
+
3
+ Ports the ``write_dockerfile`` / ``write_compose`` / ``write_entrypoint`` heredocs
4
+ from the reference ``configure.sh`` to packaged ``string.Template`` files. The one
5
+ deliberate change: the pip package is parameterised via
6
+ :attr:`RenderContext.edge_package` (default ``"mad-edge"``), and because
7
+ ``string.Template`` has no conditionals the container's start binary is resolved
8
+ here in Python — ``mad-edge`` normally, but ``mad`` for the ``mad-bros`` package,
9
+ which still installs the ``mad`` console script.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import string
15
+ from dataclasses import dataclass
16
+ from importlib import resources
17
+ from pathlib import Path
18
+
19
+ from mad_cli.core.envfile import EnvFile
20
+
21
+ EDGE_PACKAGE: str = "mad-edge"
22
+
23
+ # Rendered output name -> packaged template name.
24
+ _TEMPLATES: dict[str, str] = {
25
+ "Dockerfile": "Dockerfile.tmpl",
26
+ "compose.yml": "compose.yml.tmpl",
27
+ "entrypoint.sh": "entrypoint.sh.tmpl",
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class RenderContext:
33
+ instance: str
34
+ host_port: int
35
+ data_path: Path
36
+ timeout_s: int
37
+ puid: int
38
+ pgid: int
39
+ edge_package: str = EDGE_PACKAGE
40
+ edge_version: str = "" # '' = latest
41
+
42
+
43
+ def _entrypoint_binary(edge_package: str) -> str:
44
+ """Console script that starts the server for ``edge_package``.
45
+
46
+ ``mad-bros`` ships its server as the ``mad`` script; every other package
47
+ (``mad-edge`` and forks) exposes ``mad-edge``.
48
+ """
49
+ return "mad" if edge_package == "mad-bros" else "mad-edge"
50
+
51
+
52
+ def _load_template(template_name: str) -> string.Template:
53
+ text = resources.files("mad_cli.templates").joinpath(template_name).read_text(encoding="utf-8")
54
+ return string.Template(text)
55
+
56
+
57
+ def render_all(ctx: RenderContext) -> dict[str, str]:
58
+ """Render the three instance files from the packaged templates."""
59
+ version_spec = f"=={ctx.edge_version}" if ctx.edge_version else ""
60
+ mapping = {
61
+ "instance": ctx.instance,
62
+ "host_port": str(ctx.host_port),
63
+ "data_path": str(ctx.data_path),
64
+ "timeout_s": str(ctx.timeout_s),
65
+ "puid": str(ctx.puid),
66
+ "pgid": str(ctx.pgid),
67
+ "edge_package": ctx.edge_package,
68
+ "edge_version_spec": version_spec,
69
+ "image_tag": ctx.edge_version or "latest",
70
+ "edge_entrypoint": _entrypoint_binary(ctx.edge_package),
71
+ }
72
+ return {
73
+ out_name: _load_template(tmpl_name).substitute(mapping)
74
+ for out_name, tmpl_name in _TEMPLATES.items()
75
+ }
76
+
77
+
78
+ def write_instance_files(target: Path, ctx: RenderContext, env: EnvFile) -> None:
79
+ """Render into ``target`` and save ``env`` as ``target/.env``.
80
+
81
+ ``entrypoint.sh`` is made executable (mode ``0o755``); the ``.env`` is written
82
+ through :meth:`EnvFile.save` so its comments and ordering survive.
83
+ """
84
+ target.mkdir(parents=True, exist_ok=True)
85
+ rendered = render_all(ctx)
86
+ (target / "Dockerfile").write_text(rendered["Dockerfile"], encoding="utf-8")
87
+ (target / "compose.yml").write_text(rendered["compose.yml"], encoding="utf-8")
88
+ entrypoint = target / "entrypoint.sh"
89
+ entrypoint.write_text(rendered["entrypoint.sh"], encoding="utf-8")
90
+ entrypoint.chmod(0o755)
91
+ env.save(target / ".env")
@@ -0,0 +1,11 @@
1
+ """Framework-free use-case layer — the shared core of the CLI and the HTTP API.
2
+
3
+ Every operator capability lives here as a plain function that takes primitive
4
+ inputs, drives :mod:`mad_cli.core`, and returns dataclasses (never ``typer``,
5
+ ``rich`` or ``fastapi`` objects). The Typer commands and the FastAPI routes are
6
+ thin adapters over these functions, so the two surfaces can never drift.
7
+
8
+ Expected, user-facing failures are raised as :class:`~mad_cli.core.usecases.errors.UseCaseError`
9
+ subclasses; each adapter maps them to its own idiom (``typer.Exit(1)`` / an HTTP
10
+ status code).
11
+ """
@@ -0,0 +1,55 @@
1
+ """Adopt use case: migrate the legacy single-instance layout into ``instances/``.
2
+
3
+ The old layout kept ``compose.yml`` / ``.env`` / ``Dockerfile`` / ``entrypoint.sh``
4
+ directly under the config root; the modern layout stores them per instance. Only
5
+ those config files move — the instance's data (``MAD_DATA_PATH``) stays put.
6
+
7
+ Planning and applying are split so the CLI can show the plan and ask for
8
+ confirmation before anything moves; the API applies directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from mad_cli.core.instance import discover_instances
17
+ from mad_cli.core.paths import instance_dir
18
+ from mad_cli.core.usecases.errors import ValidationError
19
+
20
+ # Config files that make up a legacy single-instance layout and move together.
21
+ ADOPT_FILES = ("compose.yml", ".env", "Dockerfile", "entrypoint.sh")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AdoptPlan:
26
+ name: str
27
+ source: Path
28
+ target: Path
29
+ movable: list[str]
30
+
31
+
32
+ def plan_adopt() -> AdoptPlan | None:
33
+ """Return the pending adoption, or ``None`` when there is no legacy layout.
34
+
35
+ Raises :class:`ValidationError` if the legacy instance name cannot be used as
36
+ a target directory.
37
+ """
38
+ legacy = next((i for i in discover_instances() if getattr(i, "legacy", False)), None)
39
+ if legacy is None:
40
+ return None
41
+ source = legacy.config_dir
42
+ try:
43
+ target = instance_dir(legacy.name)
44
+ except ValueError as exc:
45
+ raise ValidationError(f"cannot adopt {legacy.name!r}: {exc}") from exc
46
+ movable = [name for name in ADOPT_FILES if (source / name).exists()]
47
+ return AdoptPlan(name=legacy.name, source=source, target=target, movable=movable)
48
+
49
+
50
+ def apply_adopt(plan: AdoptPlan) -> AdoptPlan:
51
+ """Move the planned config files into ``instances/<name>/``; return the plan."""
52
+ plan.target.mkdir(parents=True, exist_ok=True)
53
+ for name in plan.movable:
54
+ (plan.source / name).rename(plan.target / name)
55
+ return plan
@@ -0,0 +1,94 @@
1
+ """``.env`` editing use cases: get / set / unset with validation and masking.
2
+
3
+ The general-purpose ``.env`` editor behind ``mad config`` and the
4
+ ``/v1/instances/{name}/config`` routes. Each takes an already-resolved
5
+ :class:`Instance`. A handful of known keys are validated on write; compose-baked
6
+ keys (host port / data bind) still write to ``.env`` but the adapter warns that a
7
+ regenerate is required. Secret-looking values are masked on read via
8
+ :class:`~mad_cli.core.usecases.instances.EnvItem`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+
15
+ from mad_cli.core.instance import Instance
16
+ from mad_cli.core.keyspec import is_secret_key
17
+ from mad_cli.core.usecases.errors import NotFoundError, ValidationError
18
+ from mad_cli.core.usecases.instances import EnvItem
19
+
20
+ # Keys whose value was rendered into compose.yml at install time.
21
+ COMPOSE_KEYS: tuple[str, ...] = ("MAD_HOST_PORT", "MAD_DATA_PATH")
22
+
23
+
24
+ def _validate_port(value: str) -> str:
25
+ try:
26
+ port = int(value)
27
+ except ValueError:
28
+ raise ValidationError(f"invalid MAD_HOST_PORT {value!r}: must be an integer") from None
29
+ if not 1 <= port <= 65535:
30
+ raise ValidationError(f"invalid MAD_HOST_PORT {value!r}: must be between 1 and 65535")
31
+ return str(port)
32
+
33
+
34
+ def _validate_timeout(value: str) -> str:
35
+ try:
36
+ seconds = int(value)
37
+ except ValueError:
38
+ raise ValidationError(
39
+ f"invalid MAD_AGENT_TIMEOUT_S {value!r}: must be an integer"
40
+ ) from None
41
+ if seconds <= 0:
42
+ raise ValidationError(f"invalid MAD_AGENT_TIMEOUT_S {value!r}: must be a positive integer")
43
+ return str(seconds)
44
+
45
+
46
+ _VALIDATORS: dict[str, Callable[[str], str]] = {
47
+ "MAD_HOST_PORT": _validate_port,
48
+ "MAD_AGENT_TIMEOUT_S": _validate_timeout,
49
+ }
50
+
51
+
52
+ def _item(key: str, value: str) -> EnvItem:
53
+ return EnvItem(key=key, value=value, secret=is_secret_key(key))
54
+
55
+
56
+ def list_config(instance: Instance) -> list[EnvItem]:
57
+ """Return all ``.env`` items (values masked on display)."""
58
+ env = instance.env
59
+ return [_item(key, env.get(key) or "") for key in env.keys()] # noqa: SIM118
60
+
61
+
62
+ def get_config(instance: Instance, key: str) -> EnvItem:
63
+ """Return one ``.env`` item. Raises :class:`NotFoundError` when unset."""
64
+ value = instance.env.get(key)
65
+ if value is None:
66
+ raise NotFoundError(f"{key} is not set on {instance.name}")
67
+ return _item(key, value)
68
+
69
+
70
+ def set_config(instance: Instance, key: str, value: str) -> tuple[EnvItem, bool]:
71
+ """Validate and write a ``.env`` value.
72
+
73
+ Returns ``(item, compose_baked)`` where ``compose_baked`` is True for keys
74
+ rendered into compose.yml at install time. Raises :class:`ValidationError` for
75
+ a bad known value.
76
+ """
77
+ validator = _VALIDATORS.get(key)
78
+ if validator is not None:
79
+ value = validator(value)
80
+ instance.env.set(key, value)
81
+ instance.env.save()
82
+ return _item(key, value), key in COMPOSE_KEYS
83
+
84
+
85
+ def unset_config(instance: Instance, key: str) -> bool:
86
+ """Remove ``key`` from the instance's ``.env``.
87
+
88
+ Returns whether the key existed; False is a no-op the adapter reports.
89
+ """
90
+ if instance.env.get(key) is None:
91
+ return False
92
+ instance.env.unset(key)
93
+ instance.env.save()
94
+ return True
@@ -0,0 +1,57 @@
1
+ """Expected, user-facing failures raised by the use-case layer.
2
+
3
+ These are the vocabulary the adapters translate. The CLI turns any of them into
4
+ an ``error(...)`` line plus ``typer.Exit(1)``; the HTTP API maps each subclass to
5
+ a status code (see :data:`HTTP_STATUS`). They carry a human-readable message and
6
+ never wrap a stack trace the operator should not see.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class UseCaseError(Exception):
13
+ """Base class for expected failures surfaced to the operator."""
14
+
15
+
16
+ class ValidationError(UseCaseError, ValueError):
17
+ """An input was malformed or out of range (bad port, unknown key id, …).
18
+
19
+ Also a :class:`ValueError` so it satisfies the ``ui.prompts.ask`` validator
20
+ contract (raise ``ValueError`` to re-prompt): the same validators back the
21
+ interactive prompts and the non-interactive / HTTP paths.
22
+ """
23
+
24
+
25
+ class NotFoundError(UseCaseError):
26
+ """A referenced instance, key or value does not exist."""
27
+
28
+
29
+ class ConflictError(UseCaseError):
30
+ """The requested state clashes with what already exists (instance exists)."""
31
+
32
+
33
+ class AmbiguousInstanceError(UseCaseError):
34
+ """No instance was named and the target could not be inferred (0 or >1)."""
35
+
36
+
37
+ class PreconditionError(UseCaseError):
38
+ """A precondition is unmet (Docker missing, no data path, health failed)."""
39
+
40
+
41
+ # Adapter hint: use-case error -> HTTP status. The CLI ignores this and exits 1.
42
+ HTTP_STATUS: dict[type[UseCaseError], int] = {
43
+ ValidationError: 400,
44
+ NotFoundError: 404,
45
+ ConflictError: 409,
46
+ AmbiguousInstanceError: 409,
47
+ PreconditionError: 412,
48
+ }
49
+
50
+
51
+ def http_status_for(exc: UseCaseError) -> int:
52
+ """Return the HTTP status code for ``exc`` (500 if unmapped)."""
53
+ for cls in type(exc).__mro__:
54
+ status = HTTP_STATUS.get(cls)
55
+ if status is not None:
56
+ return status
57
+ return 500