llmport-cli 0.1.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.
- llmport/__init__.py +16 -0
- llmport/cli.py +95 -0
- llmport/commands/__init__.py +1 -0
- llmport/commands/admin.py +154 -0
- llmport/commands/config.py +146 -0
- llmport/commands/deploy.py +370 -0
- llmport/commands/dev/__init__.py +1 -0
- llmport/commands/dev/dev_doctor.py +46 -0
- llmport/commands/dev/dev_group.py +22 -0
- llmport/commands/dev/dev_init.py +555 -0
- llmport/commands/dev/dev_status.py +146 -0
- llmport/commands/dev/dev_up.py +247 -0
- llmport/commands/doctor.py +135 -0
- llmport/commands/down.py +65 -0
- llmport/commands/logs_cmd.py +41 -0
- llmport/commands/module.py +86 -0
- llmport/commands/status.py +71 -0
- llmport/commands/tune.py +99 -0
- llmport/commands/up.py +61 -0
- llmport/commands/version.py +34 -0
- llmport/core/__init__.py +1 -0
- llmport/core/api_client.py +77 -0
- llmport/core/bootstrap.py +211 -0
- llmport/core/compose.py +349 -0
- llmport/core/console.py +106 -0
- llmport/core/detect.py +375 -0
- llmport/core/env_gen.py +295 -0
- llmport/core/git.py +164 -0
- llmport/core/install.py +272 -0
- llmport/core/registry.py +302 -0
- llmport/core/settings.py +138 -0
- llmport/core/sysinfo.py +183 -0
- llmport/templates/env.j2 +33 -0
- llmport/tui/__init__.py +1 -0
- llmport/tui/widgets/__init__.py +1 -0
- llmport/tui/wizard/__init__.py +1 -0
- llmport_cli-0.1.0.dist-info/METADATA +263 -0
- llmport_cli-0.1.0.dist-info/RECORD +42 -0
- llmport_cli-0.1.0.dist-info/WHEEL +4 -0
- llmport_cli-0.1.0.dist-info/entry_points.txt +2 -0
- llmport_cli-0.1.0.dist-info/licenses/LICENSE +187 -0
- llmport_cli-0.1.0.dist-info/licenses/NOTICE +15 -0
llmport/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""llmport — CLI installer and management tool for llm.port.
|
|
2
|
+
|
|
3
|
+
Layered command structure built on Click framework.
|
|
4
|
+
Install, configure, and manage the full platform.
|
|
5
|
+
YAML-driven configuration persisted in llmport.yaml.
|
|
6
|
+
Async HTTP calls to backend REST API via httpx.
|
|
7
|
+
Native Docker Compose integration for orchestration.
|
|
8
|
+
Automatic system detection for OS, GPU, and Docker.
|
|
9
|
+
GPU vendor discovery supports NVIDIA and AMD ROCm.
|
|
10
|
+
Rich terminal output with tables, panels, progress.
|
|
11
|
+
Advanced TUI wizard powered by Textual framework.
|
|
12
|
+
Module activation via Docker Compose profiles.
|
|
13
|
+
All secrets generated securely during init phase.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
llmport/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""CLI entry point — Click root group and subcommand registration.
|
|
2
|
+
|
|
3
|
+
This module defines the ``llmport`` command group and wires up all
|
|
4
|
+
subcommands. Run ``llmport --help`` for the full command tree.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from llmport import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AliasedGroup(click.Group):
|
|
15
|
+
"""Click group that supports command abbreviations.
|
|
16
|
+
|
|
17
|
+
For instance ``llmport st`` resolves to ``llmport status`` if no
|
|
18
|
+
other command starts with ``st``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
22
|
+
rv = click.Group.get_command(self, ctx, cmd_name)
|
|
23
|
+
if rv is not None:
|
|
24
|
+
return rv
|
|
25
|
+
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
|
26
|
+
if not matches:
|
|
27
|
+
return None
|
|
28
|
+
if len(matches) == 1:
|
|
29
|
+
return click.Group.get_command(self, ctx, matches[0])
|
|
30
|
+
ctx.fail(f"Ambiguous command '{cmd_name}'. Did you mean: {', '.join(sorted(matches))}?")
|
|
31
|
+
return None # unreachable, but satisfies type checker
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group(cls=AliasedGroup, invoke_without_command=True)
|
|
35
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.")
|
|
36
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress informational output.")
|
|
37
|
+
@click.version_option(version=__version__, prog_name="llmport")
|
|
38
|
+
@click.pass_context
|
|
39
|
+
def cli(ctx: click.Context, *, verbose: bool, quiet: bool) -> None:
|
|
40
|
+
"""llmport — CLI installer and management tool for llm.port."""
|
|
41
|
+
ctx.ensure_object(dict)
|
|
42
|
+
ctx.obj["verbose"] = verbose
|
|
43
|
+
ctx.obj["quiet"] = quiet
|
|
44
|
+
|
|
45
|
+
if ctx.invoked_subcommand is None:
|
|
46
|
+
click.echo(ctx.get_help())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Register subcommands ──────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def register_core_commands(group: click.Group | None = None) -> None:
|
|
52
|
+
"""Import and register all core subcommand groups and commands.
|
|
53
|
+
|
|
54
|
+
Accepts an optional *group* parameter so the EE CLI can call this
|
|
55
|
+
with its own root group. Defaults to ``cli`` when called internally.
|
|
56
|
+
"""
|
|
57
|
+
target = group or cli
|
|
58
|
+
|
|
59
|
+
from llmport.commands.version import version_cmd
|
|
60
|
+
from llmport.commands.doctor import doctor_cmd
|
|
61
|
+
from llmport.commands.status import status_cmd
|
|
62
|
+
from llmport.commands.up import up_cmd
|
|
63
|
+
from llmport.commands.down import down_cmd
|
|
64
|
+
from llmport.commands.logs_cmd import logs_cmd
|
|
65
|
+
from llmport.commands.config import config_group
|
|
66
|
+
from llmport.commands.module import module_group
|
|
67
|
+
from llmport.commands.tune import tune_cmd
|
|
68
|
+
from llmport.commands.deploy import deploy_cmd
|
|
69
|
+
from llmport.commands.dev.dev_group import dev_group
|
|
70
|
+
from llmport.commands.admin import admin_group
|
|
71
|
+
|
|
72
|
+
target.add_command(version_cmd, "version")
|
|
73
|
+
target.add_command(doctor_cmd, "doctor")
|
|
74
|
+
target.add_command(status_cmd, "status")
|
|
75
|
+
target.add_command(up_cmd, "up")
|
|
76
|
+
target.add_command(down_cmd, "down")
|
|
77
|
+
target.add_command(logs_cmd, "logs")
|
|
78
|
+
target.add_command(config_group, "config")
|
|
79
|
+
target.add_command(module_group, "module")
|
|
80
|
+
target.add_command(tune_cmd, "tune")
|
|
81
|
+
target.add_command(deploy_cmd, "deploy")
|
|
82
|
+
target.add_command(dev_group, "dev")
|
|
83
|
+
target.add_command(admin_group, "admin")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
register_core_commands()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
"""Package entry point."""
|
|
91
|
+
cli()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command implementations for the llmport CLI."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""``llmport admin`` — administrative commands for a running deployment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import string
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from llmport.core.console import console, error, info, success, warning
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _generate_password(length: int = 24) -> str:
|
|
18
|
+
"""Generate a random password (letters + digits + punctuation subset)."""
|
|
19
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%&*-_=+"
|
|
20
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Minimal email validation — rejects obvious injection attempts.
|
|
24
|
+
_EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-.]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("admin")
|
|
28
|
+
def admin_group() -> None:
|
|
29
|
+
"""Administrative commands for a running llm.port deployment."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@admin_group.command("reset-password")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--email",
|
|
35
|
+
default="admin@localhost",
|
|
36
|
+
show_default=True,
|
|
37
|
+
help="Email address of the user whose password will be reset.",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--password",
|
|
41
|
+
default=None,
|
|
42
|
+
help="New password. Auto-generated if omitted.",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"-y", "--yes",
|
|
46
|
+
is_flag=True,
|
|
47
|
+
help="Skip confirmation prompt.",
|
|
48
|
+
)
|
|
49
|
+
def reset_password_cmd(*, email: str, password: str | None, yes: bool) -> None:
|
|
50
|
+
"""Reset a user's password directly via the database.
|
|
51
|
+
|
|
52
|
+
This bypasses the API and sets the password hash in PostgreSQL.
|
|
53
|
+
The backend container must be running (needed for password hashing).
|
|
54
|
+
"""
|
|
55
|
+
# Validate email early to prevent any injection
|
|
56
|
+
if not _EMAIL_RE.match(email):
|
|
57
|
+
error(f"Invalid email address: {email}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
docker = shutil.which("docker")
|
|
61
|
+
if not docker:
|
|
62
|
+
error("docker not found on PATH")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
new_password = password or _generate_password()
|
|
66
|
+
|
|
67
|
+
if not yes:
|
|
68
|
+
click.confirm(
|
|
69
|
+
f"Reset password for '{email}'?",
|
|
70
|
+
abort=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ── 1. Hash the password inside the backend container ─────
|
|
74
|
+
info("Hashing new password…")
|
|
75
|
+
hash_script = (
|
|
76
|
+
"from fastapi_users.password import PasswordHelper; "
|
|
77
|
+
"print(PasswordHelper().hash(input()))"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
hash_result = subprocess.run( # noqa: S603
|
|
81
|
+
[
|
|
82
|
+
docker, "exec", "-i", "llm-port-backend",
|
|
83
|
+
"python", "-c", hash_script,
|
|
84
|
+
],
|
|
85
|
+
input=new_password,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
check=False,
|
|
89
|
+
)
|
|
90
|
+
if hash_result.returncode != 0:
|
|
91
|
+
error("Failed to hash password — is the backend container running?")
|
|
92
|
+
if hash_result.stderr.strip():
|
|
93
|
+
error(f" {hash_result.stderr.strip()}")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
hashed = hash_result.stdout.strip()
|
|
97
|
+
if not hashed:
|
|
98
|
+
error("Password hashing returned empty result.")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
# ── 2. Update the password in PostgreSQL ──────────────────
|
|
102
|
+
info("Updating password in database…")
|
|
103
|
+
|
|
104
|
+
# Both `hashed` (bcrypt output we generated) and `email` (validated
|
|
105
|
+
# above by regex) are safe for SQL string interpolation here.
|
|
106
|
+
sql = (
|
|
107
|
+
f"UPDATE \"user\" SET hashed_password = '{hashed}' "
|
|
108
|
+
f"WHERE email = '{email}';"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
pg_result = subprocess.run( # noqa: S603
|
|
112
|
+
[
|
|
113
|
+
docker, "exec", "llm-port-postgres",
|
|
114
|
+
"psql", "-U", "postgres", "-d", "llm_port_backend",
|
|
115
|
+
"-c", sql,
|
|
116
|
+
],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
check=False,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if pg_result.returncode != 0:
|
|
123
|
+
error("Database update failed — is the postgres container running?")
|
|
124
|
+
if pg_result.stderr.strip():
|
|
125
|
+
error(f" {pg_result.stderr.strip()}")
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
# Check if a row was actually updated
|
|
129
|
+
if "UPDATE 0" in pg_result.stdout:
|
|
130
|
+
warning(f"No user found with email '{email}'.")
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
success(f"Password reset for '{email}'.")
|
|
134
|
+
|
|
135
|
+
# ── 3. Display credentials ────────────────────────────────
|
|
136
|
+
from rich.panel import Panel
|
|
137
|
+
from rich.text import Text
|
|
138
|
+
|
|
139
|
+
lines = Text()
|
|
140
|
+
lines.append(" Email: ", style="bold")
|
|
141
|
+
lines.append(email, style="cyan")
|
|
142
|
+
lines.append("\n Password: ", style="bold")
|
|
143
|
+
lines.append(new_password, style="cyan")
|
|
144
|
+
lines.append("\n\n Store this in a password manager.", style="dim")
|
|
145
|
+
|
|
146
|
+
console.print()
|
|
147
|
+
console.print(
|
|
148
|
+
Panel(
|
|
149
|
+
lines,
|
|
150
|
+
title="[bold yellow]New Credentials[/bold yellow]",
|
|
151
|
+
border_style="yellow",
|
|
152
|
+
padding=(1, 2),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""``llmport config`` — view and manage configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import yaml
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
|
|
12
|
+
from llmport.core.console import console, success, error
|
|
13
|
+
from llmport.core.settings import load_config, save_config, config_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group("config")
|
|
17
|
+
def config_group() -> None:
|
|
18
|
+
"""Manage llmport configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@config_group.command("show")
|
|
22
|
+
def config_show() -> None:
|
|
23
|
+
"""Display the current configuration."""
|
|
24
|
+
cfg_path = config_path()
|
|
25
|
+
|
|
26
|
+
if not cfg_path.exists():
|
|
27
|
+
console.print(f"[dim]No config file found at {cfg_path}[/dim]")
|
|
28
|
+
console.print("[dim]Using default configuration.[/dim]")
|
|
29
|
+
cfg = load_config()
|
|
30
|
+
import dataclasses
|
|
31
|
+
|
|
32
|
+
raw = yaml.dump(dataclasses.asdict(cfg), default_flow_style=False, sort_keys=False)
|
|
33
|
+
console.print(Panel(Syntax(raw, "yaml", theme="monokai"), title="defaults"))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
raw = cfg_path.read_text(encoding="utf-8")
|
|
37
|
+
console.print(
|
|
38
|
+
Panel(
|
|
39
|
+
Syntax(raw, "yaml", theme="monokai"),
|
|
40
|
+
title=str(cfg_path),
|
|
41
|
+
border_style="cyan",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@config_group.command("set")
|
|
47
|
+
@click.argument("key")
|
|
48
|
+
@click.argument("value")
|
|
49
|
+
def config_set(key: str, value: str) -> None:
|
|
50
|
+
"""Set a configuration value (dot-notation supported).
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
|
|
54
|
+
llmport config set install_dir /opt/llmport
|
|
55
|
+
|
|
56
|
+
llmport config set dev.branch main
|
|
57
|
+
"""
|
|
58
|
+
cfg = load_config()
|
|
59
|
+
import dataclasses
|
|
60
|
+
|
|
61
|
+
cfg_dict = dataclasses.asdict(cfg)
|
|
62
|
+
|
|
63
|
+
# Support dot-notation for nested keys (e.g., dev.branch)
|
|
64
|
+
parts = key.split(".")
|
|
65
|
+
target = cfg_dict
|
|
66
|
+
for part in parts[:-1]:
|
|
67
|
+
if part not in target or not isinstance(target[part], dict):
|
|
68
|
+
error(f"Unknown config section: {part}")
|
|
69
|
+
return
|
|
70
|
+
target = target[part]
|
|
71
|
+
|
|
72
|
+
final_key = parts[-1]
|
|
73
|
+
if final_key not in target:
|
|
74
|
+
error(f"Unknown config key: {key}")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Coerce value types
|
|
78
|
+
old_val = target[final_key]
|
|
79
|
+
if isinstance(old_val, bool):
|
|
80
|
+
value_typed: str | bool | int | float | list[str] = value.lower() in ("true", "1", "yes")
|
|
81
|
+
elif isinstance(old_val, int):
|
|
82
|
+
value_typed = int(value)
|
|
83
|
+
elif isinstance(old_val, float):
|
|
84
|
+
value_typed = float(value)
|
|
85
|
+
elif isinstance(old_val, list):
|
|
86
|
+
value_typed = [v.strip() for v in value.split(",")]
|
|
87
|
+
else:
|
|
88
|
+
value_typed = value
|
|
89
|
+
|
|
90
|
+
target[final_key] = value_typed
|
|
91
|
+
|
|
92
|
+
# Rebuild config
|
|
93
|
+
from llmport.core.settings import LlmportConfig, DevConfig
|
|
94
|
+
|
|
95
|
+
dev_data = cfg_dict.pop("dev", {}) or {}
|
|
96
|
+
dev_cfg = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
|
|
97
|
+
new_cfg = LlmportConfig(**cfg_dict, dev=dev_cfg)
|
|
98
|
+
|
|
99
|
+
save_config(new_cfg)
|
|
100
|
+
success(f"Set {key} = {value_typed}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@config_group.command("path")
|
|
104
|
+
def config_path_cmd() -> None:
|
|
105
|
+
"""Print the config file path."""
|
|
106
|
+
click.echo(str(config_path()))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@config_group.command("init")
|
|
110
|
+
@click.option("--force", is_flag=True, help="Overwrite existing config file.")
|
|
111
|
+
def config_init(*, force: bool) -> None:
|
|
112
|
+
"""Create a default configuration file."""
|
|
113
|
+
cfg_file = config_path()
|
|
114
|
+
|
|
115
|
+
if cfg_file.exists() and not force:
|
|
116
|
+
error(f"Config file already exists at {cfg_file}. Use --force to overwrite.")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
cfg = load_config()
|
|
120
|
+
save_config(cfg)
|
|
121
|
+
success(f"Configuration written to {cfg_file}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@config_group.command("edit")
|
|
125
|
+
def config_edit() -> None:
|
|
126
|
+
"""Open the config file in $EDITOR."""
|
|
127
|
+
import os
|
|
128
|
+
import subprocess
|
|
129
|
+
|
|
130
|
+
cfg_file = config_path()
|
|
131
|
+
if not cfg_file.exists():
|
|
132
|
+
# Create default first
|
|
133
|
+
cfg = load_config()
|
|
134
|
+
save_config(cfg)
|
|
135
|
+
|
|
136
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
|
|
137
|
+
if not editor:
|
|
138
|
+
# Fallback based on platform
|
|
139
|
+
import platform
|
|
140
|
+
|
|
141
|
+
if platform.system() == "Windows":
|
|
142
|
+
editor = "notepad"
|
|
143
|
+
else:
|
|
144
|
+
editor = "nano"
|
|
145
|
+
|
|
146
|
+
subprocess.run([editor, str(cfg_file)], check=False)
|