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
mad_cli/commands/keys.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""``mad keys set|list|remove`` — manage credentials / API keys in ``.env``.
|
|
2
|
+
|
|
3
|
+
Thin adapter over :mod:`mad_cli.core.usecases.keys`. A key is a *builtin*
|
|
4
|
+
(``github``, ``claude-oauth``, …), whose value fans out to one or more env vars,
|
|
5
|
+
or a raw *custom* ``[A-Z][A-Z0-9_]*`` variable. ``claude-oauth`` also materialises
|
|
6
|
+
the container credentials file. Values are always masked when shown.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from mad_cli.commands._adapt import fail, resolve_or_die
|
|
15
|
+
from mad_cli.core.instance import Instance
|
|
16
|
+
from mad_cli.core.usecases import keys as uc
|
|
17
|
+
from mad_cli.core.usecases.errors import UseCaseError, ValidationError
|
|
18
|
+
from mad_cli.ui.console import console, error, info, ok, warn
|
|
19
|
+
from mad_cli.ui.prompts import PromptRequiredError, ask
|
|
20
|
+
|
|
21
|
+
keys_app = typer.Typer(
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
help="Manage API keys and tokens stored in an instance's .env.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_INSTANCE_OPTION = typer.Option(
|
|
28
|
+
None, "--instance", "-i", help="Instance name (optional when exactly one instance exists)."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _restart_hint(instance: Instance) -> None:
|
|
33
|
+
info(f"Restart the instance to apply: mad restart {instance.name}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prompt_value(prompt: str, *, secret: bool) -> str:
|
|
37
|
+
try:
|
|
38
|
+
return ask(prompt, secret=secret)
|
|
39
|
+
except PromptRequiredError as exc:
|
|
40
|
+
error("A value is required. Pass it as an argument or run in an interactive terminal.")
|
|
41
|
+
raise typer.Exit(1) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@keys_app.command("set")
|
|
45
|
+
def set_key(
|
|
46
|
+
key: str = typer.Argument(
|
|
47
|
+
..., help="Builtin key id (e.g. github, claude-oauth) or a custom VAR name."
|
|
48
|
+
),
|
|
49
|
+
value: str | None = typer.Argument(None, help="Value to store. Omit to be prompted."),
|
|
50
|
+
instance: str | None = _INSTANCE_OPTION,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Store a builtin key (fanned out to its env vars) or a custom variable."""
|
|
53
|
+
inst = resolve_or_die(instance)
|
|
54
|
+
if value is None:
|
|
55
|
+
try:
|
|
56
|
+
prompt, secret = uc.key_prompt(key)
|
|
57
|
+
except ValidationError as exc:
|
|
58
|
+
fail(exc)
|
|
59
|
+
value = _prompt_value(prompt, secret=secret)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
res = uc.set_key(inst, key, value)
|
|
63
|
+
except UseCaseError as exc:
|
|
64
|
+
fail(exc)
|
|
65
|
+
|
|
66
|
+
if res.credentials_path is not None:
|
|
67
|
+
ok(f"Claude credentials → {res.credentials_path}")
|
|
68
|
+
if res.builtin:
|
|
69
|
+
ok(f"Set {res.id} ({', '.join(res.env_vars)}) on {inst.name}.")
|
|
70
|
+
else:
|
|
71
|
+
ok(f"Set {res.id} on {inst.name}.")
|
|
72
|
+
_restart_hint(inst)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@keys_app.command("list")
|
|
76
|
+
def list_keys(instance: str | None = _INSTANCE_OPTION) -> None:
|
|
77
|
+
"""Show which builtin keys are set (masked) plus any custom secret vars."""
|
|
78
|
+
inst = resolve_or_die(instance)
|
|
79
|
+
view = uc.list_keys(inst)
|
|
80
|
+
|
|
81
|
+
table = Table(title=f"Keys — {inst.name}")
|
|
82
|
+
table.add_column("Key", style="bold cyan", no_wrap=True)
|
|
83
|
+
table.add_column("Env vars")
|
|
84
|
+
table.add_column("Status")
|
|
85
|
+
table.add_column("Value")
|
|
86
|
+
for status in view.builtins:
|
|
87
|
+
table.add_row(
|
|
88
|
+
status.id,
|
|
89
|
+
", ".join(status.env_vars),
|
|
90
|
+
"set" if status.is_set else "unset",
|
|
91
|
+
status.masked,
|
|
92
|
+
)
|
|
93
|
+
console.print(table)
|
|
94
|
+
|
|
95
|
+
if view.custom:
|
|
96
|
+
ctable = Table(title="Custom secrets")
|
|
97
|
+
ctable.add_column("Env var", style="bold", no_wrap=True)
|
|
98
|
+
ctable.add_column("Value")
|
|
99
|
+
for secret in view.custom:
|
|
100
|
+
ctable.add_row(secret.key, secret.masked)
|
|
101
|
+
console.print(ctable)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@keys_app.command("remove")
|
|
105
|
+
def remove_key(
|
|
106
|
+
key: str = typer.Argument(..., help="Builtin key id or custom VAR name to remove."),
|
|
107
|
+
instance: str | None = _INSTANCE_OPTION,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Remove a builtin key's env vars (or a custom variable) from ``.env``."""
|
|
110
|
+
inst = resolve_or_die(instance)
|
|
111
|
+
try:
|
|
112
|
+
res = uc.remove_key(inst, key)
|
|
113
|
+
except UseCaseError as exc:
|
|
114
|
+
fail(exc)
|
|
115
|
+
|
|
116
|
+
if not res.existed:
|
|
117
|
+
warn(f"{res.id} is not set on {inst.name}; nothing to remove.")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if res.builtin:
|
|
121
|
+
ok(f"Removed {res.id} ({', '.join(res.env_vars)}) from {inst.name}.")
|
|
122
|
+
else:
|
|
123
|
+
ok(f"Removed {res.id} from {inst.name}.")
|
|
124
|
+
if res.credentials_left is not None:
|
|
125
|
+
warn(f"Claude credentials file left in place on disk: {res.credentials_left}")
|
|
126
|
+
_restart_hint(inst)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""``mad start|stop|restart|status|logs|shell [INSTANCE]`` — container lifecycle.
|
|
2
|
+
|
|
3
|
+
Thin adapter over :mod:`mad_cli.core.usecases.lifecycle`. ``INSTANCE`` is optional:
|
|
4
|
+
when omitted it resolves to the single configured instance; with zero the user is
|
|
5
|
+
pointed at ``mad install``, with several the ambiguity is reported. ``logs`` and
|
|
6
|
+
``shell`` attach the caller's TTY and stay here (no HTTP equivalent).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from mad_cli.commands._adapt import resolve_or_die
|
|
14
|
+
from mad_cli.core.compose import ComposeRunner
|
|
15
|
+
from mad_cli.core.usecases import lifecycle as uc
|
|
16
|
+
from mad_cli.ui.console import console, header, info, ok, run_step, warn
|
|
17
|
+
|
|
18
|
+
_INSTANCE_ARG = typer.Argument(
|
|
19
|
+
None, help="Instance name (optional when exactly one instance exists)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def start(instance: str | None = _INSTANCE_ARG) -> None:
|
|
24
|
+
"""Build if needed, start the instance, and wait for it to become healthy."""
|
|
25
|
+
inst = resolve_or_die(instance)
|
|
26
|
+
header(f"Starting {inst.name}")
|
|
27
|
+
res = run_step("Building and starting…", lambda: uc.start(inst))
|
|
28
|
+
if res.healthy:
|
|
29
|
+
ok(f"{inst.name} is up — {res.url}" if res.url else f"{inst.name} is up.")
|
|
30
|
+
else:
|
|
31
|
+
warn(f"{inst.name} started but is not healthy yet. Check `mad status` and `mad logs`.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stop(instance: str | None = _INSTANCE_ARG) -> None:
|
|
35
|
+
"""Stop the instance and remove its containers."""
|
|
36
|
+
inst = resolve_or_die(instance)
|
|
37
|
+
run_step(f"Stopping {inst.name}…", lambda: uc.stop(inst))
|
|
38
|
+
ok(f"{inst.name} stopped.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def restart(instance: str | None = _INSTANCE_ARG) -> None:
|
|
42
|
+
"""Restart the instance's containers."""
|
|
43
|
+
inst = resolve_or_die(instance)
|
|
44
|
+
run_step(f"Restarting {inst.name}…", lambda: uc.restart(inst))
|
|
45
|
+
ok(f"{inst.name} restarted.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status(instance: str | None = _INSTANCE_ARG) -> None:
|
|
49
|
+
"""Show container state, a health summary and the instance URL."""
|
|
50
|
+
inst = resolve_or_die(instance)
|
|
51
|
+
header(f"Status — {inst.name}")
|
|
52
|
+
res = uc.status(inst)
|
|
53
|
+
if res.ps_text.strip():
|
|
54
|
+
console.print(res.ps_text)
|
|
55
|
+
info(f"Health: {res.health}")
|
|
56
|
+
if res.url:
|
|
57
|
+
info(f"URL: {res.url}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def logs(instance: str | None = _INSTANCE_ARG) -> None:
|
|
61
|
+
"""Follow the instance's container logs."""
|
|
62
|
+
inst = resolve_or_die(instance)
|
|
63
|
+
ComposeRunner(inst).logs(follow=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def shell(instance: str | None = _INSTANCE_ARG) -> None:
|
|
67
|
+
"""Open an interactive shell inside the running container."""
|
|
68
|
+
inst = resolve_or_die(instance)
|
|
69
|
+
ComposeRunner(inst).shell()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""``mad profiles create|list|show|delete|apply`` — named environment profiles.
|
|
2
|
+
|
|
3
|
+
A *profile* is a reusable, named set of ``.env`` values (credentials + tuning,
|
|
4
|
+
never instance identity) stored under ``config_root()/profiles/<name>.env``. Use
|
|
5
|
+
it to stamp consistent config across instances: ``mad profiles apply`` overlays a
|
|
6
|
+
profile onto an existing instance's ``.env``, and ``mad install --profile`` feeds
|
|
7
|
+
a profile's values as the wizard's defaults.
|
|
8
|
+
|
|
9
|
+
The engine lives in :mod:`mad_cli.core.profiles`; this module is the Typer
|
|
10
|
+
surface, so it never touches the filesystem directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from mad_cli.commands._common import is_secret_key
|
|
22
|
+
from mad_cli.core.envfile import EnvFile
|
|
23
|
+
from mad_cli.core.instance import InstanceNotFoundError, get_instance
|
|
24
|
+
from mad_cli.core.keyspec import mask
|
|
25
|
+
from mad_cli.core.profiles import (
|
|
26
|
+
IDENTITY_KEYS,
|
|
27
|
+
ProfileNotFoundError,
|
|
28
|
+
delete_profile,
|
|
29
|
+
list_profiles,
|
|
30
|
+
load_profile,
|
|
31
|
+
save_profile,
|
|
32
|
+
)
|
|
33
|
+
from mad_cli.ui.console import console, error, info, ok, warn
|
|
34
|
+
from mad_cli.ui.prompts import confirm
|
|
35
|
+
|
|
36
|
+
profiles_app = typer.Typer(
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
help="Manage reusable named environment profiles (credentials + tuning).",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# A profile variable name: a shell env-var name (leading underscore allowed so
|
|
43
|
+
# keys like _CLAUDE_OAUTH_TOKEN round-trip).
|
|
44
|
+
_ENV_KEY_RE = re.compile(r"[A-Z_][A-Z0-9_]*")
|
|
45
|
+
|
|
46
|
+
# Module-level singleton for the repeatable --set option (a mutable-typed default
|
|
47
|
+
# may not be an inline call — flake8-bugbear B008).
|
|
48
|
+
_SET_OPTION = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
"--set",
|
|
51
|
+
metavar="KEY=VALUE",
|
|
52
|
+
help="Set a KEY=VALUE pair in the profile (repeatable).",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _interactive() -> bool:
|
|
57
|
+
"""True only when stdin is a TTY, so a prompt will not block."""
|
|
58
|
+
try:
|
|
59
|
+
return sys.stdin.isatty()
|
|
60
|
+
except (ValueError, OSError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_set(item: str) -> tuple[str, str]:
|
|
65
|
+
"""Split a ``KEY=VALUE`` --set entry, validating the key, or exit."""
|
|
66
|
+
key, sep, value = item.partition("=")
|
|
67
|
+
key = key.strip()
|
|
68
|
+
if not sep or not key:
|
|
69
|
+
error(f"invalid --set {item!r}: expected KEY=VALUE.")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
if not _ENV_KEY_RE.fullmatch(key):
|
|
72
|
+
error(f"invalid key {key!r}: must match [A-Z_][A-Z0-9_]* (an env-var name).")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
return key, value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_profile(name: str) -> EnvFile:
|
|
78
|
+
"""Load profile ``name`` to an :class:`EnvFile`, or exit with a hint."""
|
|
79
|
+
try:
|
|
80
|
+
return load_profile(name)
|
|
81
|
+
except ProfileNotFoundError as exc:
|
|
82
|
+
error(f"Profile {name!r} not found. Run `mad profiles list` to see available profiles.")
|
|
83
|
+
raise typer.Exit(1) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _display(key: str, value: str, *, reveal: bool) -> str:
|
|
87
|
+
"""Mask a secret-looking value unless ``reveal`` is set."""
|
|
88
|
+
if reveal or not value or not is_secret_key(key):
|
|
89
|
+
return value
|
|
90
|
+
return mask(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _print_env(title: str, env: EnvFile, *, reveal: bool) -> None:
|
|
94
|
+
"""Print a profile's variables as a table (secret-looking values masked)."""
|
|
95
|
+
table = Table(title=title)
|
|
96
|
+
table.add_column("Key", style="bold cyan", no_wrap=True)
|
|
97
|
+
table.add_column("Value")
|
|
98
|
+
for key in env.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API, not a dict
|
|
99
|
+
table.add_row(key, _display(key, env.get(key) or "", reveal=reveal))
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _prompt_pairs(env: EnvFile) -> None:
|
|
104
|
+
"""Interactive mini-loop to add KEY=VALUE pairs to a new profile."""
|
|
105
|
+
if not confirm("Add environment variables now?", default=False):
|
|
106
|
+
return
|
|
107
|
+
while True:
|
|
108
|
+
key = console.input("[cyan]?[/cyan] Variable name (empty to finish): ").strip()
|
|
109
|
+
if not key:
|
|
110
|
+
break
|
|
111
|
+
if not _ENV_KEY_RE.fullmatch(key):
|
|
112
|
+
warn(f"invalid key {key!r}: must match [A-Z_][A-Z0-9_]*.")
|
|
113
|
+
continue
|
|
114
|
+
# Hide the value only when the key looks like a secret.
|
|
115
|
+
value = console.input(f"[cyan]?[/cyan] Value for {key}: ", password=is_secret_key(key))
|
|
116
|
+
env.set(key, value)
|
|
117
|
+
if not confirm("Add another?", default=False):
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@profiles_app.command("create")
|
|
122
|
+
def create(
|
|
123
|
+
name: str = typer.Argument(..., help="Profile name ([a-z0-9][a-z0-9-]*)."),
|
|
124
|
+
from_instance: str | None = typer.Option(
|
|
125
|
+
None,
|
|
126
|
+
"--from-instance",
|
|
127
|
+
help="Seed the profile from an instance's .env (identity keys excluded).",
|
|
128
|
+
),
|
|
129
|
+
set_: list[str] | None = _SET_OPTION,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Create a profile, empty or seeded from an instance, plus optional KEY=VALUEs."""
|
|
132
|
+
if name in list_profiles():
|
|
133
|
+
error(f"Profile {name!r} already exists. Delete it first or pick another name.")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
env = EnvFile.empty()
|
|
137
|
+
|
|
138
|
+
if from_instance is not None:
|
|
139
|
+
try:
|
|
140
|
+
source = get_instance(from_instance)
|
|
141
|
+
except InstanceNotFoundError as exc:
|
|
142
|
+
error(f"Instance {from_instance!r} not found. Run `mad list` to see instances.")
|
|
143
|
+
raise typer.Exit(1) from exc
|
|
144
|
+
for key in source.env.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API
|
|
145
|
+
if key in IDENTITY_KEYS:
|
|
146
|
+
continue
|
|
147
|
+
value = source.env.get(key)
|
|
148
|
+
if value is not None:
|
|
149
|
+
env.set(key, value)
|
|
150
|
+
|
|
151
|
+
for item in set_ or []:
|
|
152
|
+
key, value = _split_set(item)
|
|
153
|
+
env.set(key, value)
|
|
154
|
+
|
|
155
|
+
if _interactive():
|
|
156
|
+
_prompt_pairs(env)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
path = save_profile(name, env)
|
|
160
|
+
except ValueError as exc: # invalid name
|
|
161
|
+
error(str(exc))
|
|
162
|
+
raise typer.Exit(1) from exc
|
|
163
|
+
|
|
164
|
+
ok(f"Created profile {name!r} ({len(env.keys())} variable(s)) → {path}")
|
|
165
|
+
if env.keys():
|
|
166
|
+
_print_env(f"Profile — {name}", env, reveal=False)
|
|
167
|
+
info(f"Apply it to an instance with: mad profiles apply {name} <instance>")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@profiles_app.command("list")
|
|
171
|
+
def list_( # noqa: A001 — command name; the Typer name is "list"
|
|
172
|
+
) -> None:
|
|
173
|
+
"""List every stored profile with its variable count."""
|
|
174
|
+
names = list_profiles()
|
|
175
|
+
if not names:
|
|
176
|
+
info("No profiles yet. Create one with `mad profiles create NAME`.")
|
|
177
|
+
return
|
|
178
|
+
table = Table(title="Profiles")
|
|
179
|
+
table.add_column("Profile", style="bold cyan", no_wrap=True)
|
|
180
|
+
table.add_column("Variables", justify="right")
|
|
181
|
+
for name in names:
|
|
182
|
+
env = load_profile(name)
|
|
183
|
+
table.add_row(name, str(len(env.keys())))
|
|
184
|
+
console.print(table)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@profiles_app.command("show")
|
|
188
|
+
def show(
|
|
189
|
+
name: str = typer.Argument(..., help="Profile to display."),
|
|
190
|
+
reveal: bool = typer.Option(False, "--reveal", help="Show secret values in full."),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Print a profile's variables (secret-looking values masked)."""
|
|
193
|
+
env = _resolve_profile(name)
|
|
194
|
+
if not env.keys():
|
|
195
|
+
info(f"Profile {name!r} is empty.")
|
|
196
|
+
return
|
|
197
|
+
_print_env(f"Profile — {name}", env, reveal=reveal)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@profiles_app.command("delete")
|
|
201
|
+
def delete(
|
|
202
|
+
name: str = typer.Argument(..., help="Profile to delete."),
|
|
203
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Delete a profile (asks for confirmation unless --yes)."""
|
|
206
|
+
if name not in list_profiles():
|
|
207
|
+
error(f"Profile {name!r} not found. Run `mad profiles list` to see available profiles.")
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
if not yes and not confirm(f"Delete profile {name!r}?", default=False):
|
|
210
|
+
info("Aborted; nothing was deleted.")
|
|
211
|
+
return
|
|
212
|
+
delete_profile(name)
|
|
213
|
+
ok(f"Deleted profile {name!r}.")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@profiles_app.command("apply")
|
|
217
|
+
def apply(
|
|
218
|
+
name: str = typer.Argument(..., help="Profile to apply."),
|
|
219
|
+
instance: str = typer.Argument(..., help="Instance to overlay the profile onto."),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Overlay a profile's variables onto an instance's ``.env``."""
|
|
222
|
+
profile = _resolve_profile(name)
|
|
223
|
+
try:
|
|
224
|
+
inst = get_instance(instance)
|
|
225
|
+
except InstanceNotFoundError as exc:
|
|
226
|
+
error(f"Instance {instance!r} not found. Run `mad list` to see available instances.")
|
|
227
|
+
raise typer.Exit(1) from exc
|
|
228
|
+
|
|
229
|
+
applied = 0
|
|
230
|
+
for key in profile.keys(): # noqa: SIM118 — EnvFile.keys() is its contract API
|
|
231
|
+
value = profile.get(key)
|
|
232
|
+
if value is not None:
|
|
233
|
+
inst.env.set(key, value)
|
|
234
|
+
applied += 1
|
|
235
|
+
inst.env.save()
|
|
236
|
+
|
|
237
|
+
ok(f"Applied {applied} variable(s) from profile {name!r} to instance {inst.name!r}.")
|
|
238
|
+
info(f"Restart the instance to apply: mad restart {inst.name}")
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""``mad serve`` and ``mad service install|uninstall|status|update`` — service mode.
|
|
2
|
+
|
|
3
|
+
``serve`` runs the HTTP API in the foreground (uvicorn). ``service`` manages a
|
|
4
|
+
boot-persistent background service: a systemd **user** unit on Linux, a launchd
|
|
5
|
+
LaunchAgent on macOS. When the ``server`` extra is not importable, ``service
|
|
6
|
+
install`` auto-provisions a dedicated venv under ``config_root()/server-venv`` and
|
|
7
|
+
points the unit at it — the base CLI never needs FastAPI installed.
|
|
8
|
+
|
|
9
|
+
Thin adapter over :mod:`mad_cli.core.usecases.service`. Activating the unit
|
|
10
|
+
(``systemctl`` / ``launchctl``) happens only for a real install; ``--render-to``
|
|
11
|
+
writes the file and touches nothing on the live system (used by tests and the E2E).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from mad_cli import __version__
|
|
24
|
+
from mad_cli.commands._adapt import fail
|
|
25
|
+
from mad_cli.core.paths import config_root
|
|
26
|
+
from mad_cli.core.usecases import service as uc
|
|
27
|
+
from mad_cli.core.usecases.errors import UseCaseError
|
|
28
|
+
from mad_cli.ui.console import error, header, info, ok, warn
|
|
29
|
+
|
|
30
|
+
service_app = typer.Typer(
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
help="Install/manage the mad HTTP API as a boot-persistent background service.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_HOST_OPTION = typer.Option(uc.DEFAULT_HOST, "--host", help="Bind address (default: 127.0.0.1).")
|
|
37
|
+
_PORT_OPTION = typer.Option(uc.DEFAULT_PORT, "--port", help="Bind port (default: 7373).")
|
|
38
|
+
_WHEEL_OPTION = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--wheel",
|
|
41
|
+
"--from",
|
|
42
|
+
help="Provision the server venv from this local wheel/sdist instead of PyPI.",
|
|
43
|
+
)
|
|
44
|
+
_RENDER_TO_OPTION = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
"--render-to",
|
|
47
|
+
help="Write the unit/plist to PATH and stop — do not touch systemctl/launchctl.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _warn_public_bind(host: str) -> None:
|
|
52
|
+
if uc.is_loopback(host):
|
|
53
|
+
return
|
|
54
|
+
bar = "!" * 68
|
|
55
|
+
warn(bar)
|
|
56
|
+
warn(f"Binding to {host} exposes the mad API BEYOND localhost.")
|
|
57
|
+
warn("Anyone who can reach this address and holds the bearer token can control")
|
|
58
|
+
warn("your instances (start/stop, config, keys). Put it behind a firewall/VPN.")
|
|
59
|
+
warn(bar)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _platform() -> str:
|
|
63
|
+
return platform.system().lower()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── mad serve ─────────────────────────────────────────────────────────────────
|
|
67
|
+
def serve(host: str = _HOST_OPTION, port: int = _PORT_OPTION) -> None:
|
|
68
|
+
"""Run the HTTP API in the foreground (Ctrl-C to stop)."""
|
|
69
|
+
uc.ensure_api_token() # create the token file on first run
|
|
70
|
+
_warn_public_bind(host)
|
|
71
|
+
|
|
72
|
+
if uc.server_deps_available():
|
|
73
|
+
import uvicorn # noqa: PLC0415 — optional dependency, imported on demand
|
|
74
|
+
|
|
75
|
+
from mad_cli.server import create_app # lazy: only when the extra is present
|
|
76
|
+
|
|
77
|
+
app = create_app()
|
|
78
|
+
header("mad API")
|
|
79
|
+
info(f"Listening on http://{host}:{port}")
|
|
80
|
+
info(f"Bearer token: {uc.api_token_path()} (send as `Authorization: Bearer <token>`)")
|
|
81
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if uc.server_venv_exists():
|
|
85
|
+
argv = uc.serve_argv([str(uc.server_venv_mad())], host, port)
|
|
86
|
+
info("The server extra is not in this environment — handing off to the dedicated venv.")
|
|
87
|
+
os.execv(argv[0], argv) # replace this process; never returns
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
error(
|
|
91
|
+
"The HTTP API needs the 'server' extra. Install it with "
|
|
92
|
+
"`pip install 'mad-cli[server]'`, or run `mad service install` "
|
|
93
|
+
"(it auto-provisions a dedicated environment for you)."
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── mad service install / uninstall / status / update ─────────────────────────
|
|
99
|
+
def _activate(platform_name: str, unit_path: Path) -> None:
|
|
100
|
+
"""Enable + start the freshly installed service (real installs only)."""
|
|
101
|
+
if platform_name == "linux":
|
|
102
|
+
try:
|
|
103
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
104
|
+
subprocess.run(
|
|
105
|
+
["systemctl", "--user", "enable", "--now", uc.SYSTEMD_UNIT_NAME], check=True
|
|
106
|
+
)
|
|
107
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
108
|
+
warn(f"Could not activate the systemd unit automatically: {exc}")
|
|
109
|
+
return
|
|
110
|
+
ok("Enabled and started (systemctl --user enable --now).")
|
|
111
|
+
info(
|
|
112
|
+
"To survive logout/reboot without an active session, run: loginctl enable-linger $USER"
|
|
113
|
+
)
|
|
114
|
+
elif platform_name == "darwin":
|
|
115
|
+
try:
|
|
116
|
+
subprocess.run(["launchctl", "load", str(unit_path)], check=True)
|
|
117
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
118
|
+
warn(f"Could not load the LaunchAgent automatically: {exc}")
|
|
119
|
+
return
|
|
120
|
+
ok("Loaded (launchctl load).")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@service_app.command("install")
|
|
124
|
+
def install(
|
|
125
|
+
host: str = _HOST_OPTION,
|
|
126
|
+
port: int = _PORT_OPTION,
|
|
127
|
+
wheel: Path | None = _WHEEL_OPTION,
|
|
128
|
+
render_to: Path | None = _RENDER_TO_OPTION,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Render the service file (and provision the server venv if needed)."""
|
|
131
|
+
uc.ensure_api_token()
|
|
132
|
+
system = _platform()
|
|
133
|
+
|
|
134
|
+
header("Provisioning the server runtime")
|
|
135
|
+
try:
|
|
136
|
+
launcher, bootstrapped = uc.ensure_server_runtime(wheel=wheel)
|
|
137
|
+
except UseCaseError as exc:
|
|
138
|
+
fail(exc)
|
|
139
|
+
if bootstrapped:
|
|
140
|
+
ok(f"Server venv ready → {uc.server_venv_dir()}")
|
|
141
|
+
else:
|
|
142
|
+
info("Using the current environment's `mad` (the server extra is already installed).")
|
|
143
|
+
|
|
144
|
+
exec_args = uc.serve_argv(launcher, host, port)
|
|
145
|
+
try:
|
|
146
|
+
rendered = uc.render_service(platform=system, exec_args=exec_args, config_dir=config_root())
|
|
147
|
+
except UseCaseError as exc:
|
|
148
|
+
fail(exc)
|
|
149
|
+
|
|
150
|
+
target = render_to if render_to is not None else rendered.default_path
|
|
151
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
target.write_text(rendered.content, encoding="utf-8")
|
|
153
|
+
ok(f"Service file → {target}")
|
|
154
|
+
|
|
155
|
+
if render_to is not None:
|
|
156
|
+
info("Rendered only (--render-to) — systemctl/launchctl were not touched.")
|
|
157
|
+
return
|
|
158
|
+
_activate(rendered.platform, rendered.default_path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@service_app.command("uninstall")
|
|
162
|
+
def uninstall() -> None:
|
|
163
|
+
"""Stop and remove the installed service file."""
|
|
164
|
+
system = _platform()
|
|
165
|
+
try:
|
|
166
|
+
rendered_path = uc.systemd_unit_path() if system == "linux" else uc.launchd_plist_path()
|
|
167
|
+
except Exception: # noqa: BLE001 - defensive; unsupported platform
|
|
168
|
+
info("No supported service manager on this platform.")
|
|
169
|
+
return
|
|
170
|
+
if not rendered_path.exists():
|
|
171
|
+
info("No service file installed; nothing to remove.")
|
|
172
|
+
return
|
|
173
|
+
if system == "linux":
|
|
174
|
+
try:
|
|
175
|
+
subprocess.run(
|
|
176
|
+
["systemctl", "--user", "disable", "--now", uc.SYSTEMD_UNIT_NAME], check=False
|
|
177
|
+
)
|
|
178
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
179
|
+
warn(f"Could not stop the unit: {exc}")
|
|
180
|
+
elif system == "darwin":
|
|
181
|
+
try:
|
|
182
|
+
subprocess.run(["launchctl", "unload", str(rendered_path)], check=False)
|
|
183
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
184
|
+
warn(f"Could not unload the LaunchAgent: {exc}")
|
|
185
|
+
rendered_path.unlink()
|
|
186
|
+
ok(f"Removed {rendered_path}. The server venv (if any) was left in place.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@service_app.command("status")
|
|
190
|
+
def status() -> None:
|
|
191
|
+
"""Report the service file, the server venv and version alignment."""
|
|
192
|
+
system = _platform()
|
|
193
|
+
path = uc.systemd_unit_path() if system == "linux" else uc.launchd_plist_path()
|
|
194
|
+
header("mad service status")
|
|
195
|
+
info(f"Platform: {system}")
|
|
196
|
+
info(f"Service file: {path} ({'present' if path.exists() else 'absent'})")
|
|
197
|
+
|
|
198
|
+
if uc.server_venv_exists():
|
|
199
|
+
venv_version = uc.server_venv_version()
|
|
200
|
+
ok(f"Server venv: {uc.server_venv_dir()} (mad-cli {venv_version or 'unknown'})")
|
|
201
|
+
if venv_version is not None and venv_version != __version__:
|
|
202
|
+
warn(
|
|
203
|
+
f"Server venv runs mad-cli {venv_version} but this CLI is {__version__}. "
|
|
204
|
+
"Run `mad service update` to realign."
|
|
205
|
+
)
|
|
206
|
+
elif uc.server_deps_available():
|
|
207
|
+
info("Server venv: not provisioned (the current environment has the server extra).")
|
|
208
|
+
else:
|
|
209
|
+
info("Server venv: not provisioned. `mad service install` will create one.")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@service_app.command("update")
|
|
213
|
+
def update(wheel: Path | None = _WHEEL_OPTION) -> None:
|
|
214
|
+
"""Reinstall ``mad-cli[server]`` into the server venv at the CLI's version."""
|
|
215
|
+
header("Updating the server venv")
|
|
216
|
+
try:
|
|
217
|
+
uc.bootstrap_server_venv(wheel=wheel)
|
|
218
|
+
except UseCaseError as exc:
|
|
219
|
+
fail(exc)
|
|
220
|
+
ok(f"Server venv realigned to mad-cli {__version__} → {uc.server_venv_dir()}")
|