diff --git a/commands/agent.py b/commands/agent.py index da7c8ad..1b0fd1f 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -1,23 +1,41 @@ -import typer +import os import secrets import uuid -import os from pathlib import Path from typing import Optional + +import typer from rich.panel import Panel -from rich.prompt import Prompt, Confirm, IntPrompt -from core.utils import console, print_banner, check_system, get_free_port -from core.config import write_file, write_env_file, add_db_to_json +from rich.prompt import Confirm, IntPrompt, Prompt +from rich.table import Table + +from core.config import add_db_to_json, load_db_config, write_env_file, write_file from core.docker import ensure_network, run_compose from core.network import fetch_template -from templates.compose import AGENT_POSTGRES_SNIPPET, AGENT_MARIADB_SNIPPET, AGENT_MONGODB_AUTH_SNIPPET, \ - AGENT_MONGODB_SNIPPET +from core.utils import ( + check_system, + console, + get_free_port, + get_random_hint, + print_banner, + validate_edge_key, +) +from templates.compose import ( + AGENT_MARIADB_SNIPPET, + AGENT_MONGODB_AUTH_SNIPPET, + AGENT_MONGODB_SNIPPET, + AGENT_POSTGRES_SNIPPET, +) def agent( - name: str = typer.Argument(..., help="Name of the agent (creates a folder)"), - key: Optional[str] = typer.Option(None, "--key", "-k", help="Edge Key"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately") + name: str = typer.Argument(..., help="Name of the agent (creates a folder)"), + key: Optional[str] = typer.Option(None, "--key", "-k", help="Edge Key"), + tz: str = typer.Option("UTC", "--tz", help="Timezone"), + polling: int = typer.Option(5, "--polling", help="Polling frequency in seconds"), + env: str = typer.Option("production", "--env", help="Application environment"), + data_path: str = typer.Option("/data", "--data-path", help="Internal data path"), + start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), ): print_banner() check_system() @@ -35,15 +53,42 @@ def agent( if not key: key = Prompt.ask("[key]Edge Key[/key]") + if not validate_edge_key(key): + console.print( + "[danger]✖ Invalid Edge Key. Please check the format (Base64 or JSON).[/danger]" + ) + raise typer.Exit(1) + + if not tz or tz == "UTC": + tz = Prompt.ask("Timezone", default="UTC") + + if polling == 5: + polling = IntPrompt.ask("Polling frequency (seconds)", default=5) + + if env == "production": + env = Prompt.ask( + "Environment", + choices=["production", "staging", "development"], + default="production", + ) + + if data_path == "/data": + data_path = Prompt.ask("Internal Data Path", default="/data") + raw_template = fetch_template("agent.yml") env_vars = { "EDGE_KEY": key, - "PROJECT_NAME": project_name + "PROJECT_NAME": project_name, + "TZ": tz, + "POLLING": str(polling), + "APP_ENV": env, + "DATA_PATH": data_path, } extra_services = "" extra_volumes = "volumes:\n" + app_volumes = ["./databases.json:/config/config.json"] volumes_list = [] json_path = path / "databases.json" @@ -58,35 +103,113 @@ def agent( console.print(Panel("[bold]Database Setup[/bold]", style="cyan")) while Confirm.ask("Do you want to configure a database?", default=True): - mode = Prompt.ask("Configuration Mode", choices=["new", "existing"], default="new") + mode = Prompt.ask( + "Configuration Mode", choices=["new", "existing"], default="new" + ) if mode == "existing": console.print("[info]External/Existing Database Configuration[/info]") - db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb", "mongodb"], default="postgresql") + category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") + + if category == "SQL": + db_type = Prompt.ask( + "Type", + choices=["postgresql", "mysql", "mariadb", "sqlite"], + default="postgresql", + ) + else: + db_type = Prompt.ask( + "Type", + choices=["mongodb"], + default="mongodb", + ) + friendly_name = Prompt.ask("Display Name", default="External DB") - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask("Port", default=5432 if db_type == "postgresql" else 3306) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - add_db_to_json(path, { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()) - }) + + if db_type == "sqlite": + db_name = Prompt.ask("Database Path (relative or absolute)") + if not db_name.startswith("/"): + app_volumes.append(f"./{db_name}:/config/{db_name}") + container_path = f"/config/{db_name}" + else: + container_path = db_name + + add_db_to_json( + path, + { + "name": friendly_name, + "database": container_path, + "type": db_type, + "generated_id": str(uuid.uuid4()), + }, + ) + else: + db_name = Prompt.ask("Database Name") + host = Prompt.ask("Host", default="localhost") + port = IntPrompt.ask( + "Port", + default=5432 + if db_type == "postgresql" + else (3306 if db_type in ["mysql", "mariadb"] else 27017), + ) + user = Prompt.ask("Username") + password = Prompt.ask("Password", password=True) + + add_db_to_json( + path, + { + "name": friendly_name, + "database": db_name, + "type": db_type, + "username": user, + "password": password, + "port": port, + "host": host, + "generated_id": str(uuid.uuid4()), + }, + ) console.print("[success]✔ Added to config[/success]") else: console.print("[info]New Local Docker Container[/info]") - db_engine = Prompt.ask("Engine", choices=["postgresql", "mysql", "mariadb", "mongodb-auth", "mongodb"], default="postgresql") - - if db_engine == "postgresql": + category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") + + if category == "SQL": + db_engine = Prompt.ask( + "Engine", + choices=["postgresql", "mysql", "mariadb", "sqlite"], + default="postgresql", + ) + db_variant = "standard" + else: + db_engine = Prompt.ask( + "Engine", + choices=["mongodb"], + default="mongodb", + ) + db_variant = Prompt.ask( + "Type", choices=["standard", "with-auth"], default="standard" + ) + + if db_engine == "sqlite": + db_name = Prompt.ask("Database Name", default="local") + if not db_name.endswith(".sqlite"): + db_name += ".sqlite" + + app_volumes.append(f"./{db_name}:/config/{db_name}") + + add_db_to_json( + path, + { + "name": db_name, + "database": f"/config/{db_name}", + "type": "sqlite", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") + + elif db_engine == "postgresql": pg_port = get_free_port() db_user = "admin" db_pass = secrets.token_hex(8) @@ -99,28 +222,34 @@ def agent( env_vars[f"{var_prefix}_USER"] = db_user env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = AGENT_POSTGRES_SNIPPET \ - .replace("${SERVICE_NAME}", service_name) \ - .replace("${PORT}", f"${{{var_prefix}_PORT}}") \ - .replace("${VOL_NAME}", f"{service_name}-data") \ - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") \ - .replace("${USER}", f"${{{var_prefix}_USER}}") \ + snippet = ( + AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) extra_services += snippet volumes_list.append(f"{service_name}-data") - add_db_to_json(path, { - "name": db_name, - "database": db_name, - "type": "postgresql", - "username": db_user, - "password": db_pass, - "port": pg_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added Postgres container (Port {pg_port})[/success]") + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "postgresql", + "username": db_user, + "password": db_pass, + "port": pg_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added Postgres container (Port {pg_port})[/success]" + ) elif db_engine == "mariadb" or db_engine == "mysql": mysql_port = get_free_port() @@ -135,97 +264,113 @@ def agent( env_vars[f"{var_prefix}_USER"] = db_user env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = AGENT_MARIADB_SNIPPET \ - .replace("${SERVICE_NAME}", service_name) \ - .replace("${PORT}", f"${{{var_prefix}_PORT}}") \ - .replace("${VOL_NAME}", f"{service_name}-data") \ - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") \ - .replace("${USER}", f"${{{var_prefix}_USER}}") \ - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json(path, { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": mysql_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]") - - elif db_engine == "mongodb-auth": - mongo_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = AGENT_MONGODB_AUTH_SNIPPET \ - .replace("${SERVICE_NAME}", service_name) \ - .replace("${PORT}", f"${{{var_prefix}_PORT}}") \ - .replace("${VOL_NAME}", f"{service_name}-data") \ - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") \ - .replace("${USER}", f"${{{var_prefix}_USER}}") \ + snippet = ( + AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) extra_services += snippet volumes_list.append(f"{service_name}-data") - add_db_to_json(path, { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": db_user, - "password": db_pass, - "port": mongo_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]") - + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": db_engine, + "username": db_user, + "password": db_pass, + "port": mysql_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]" + ) elif db_engine == "mongodb": - mongo_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - - snippet = AGENT_MONGODB_SNIPPET \ - .replace("${SERVICE_NAME}", service_name) \ - .replace("${PORT}", f"${{{var_prefix}_PORT}}") \ - .replace("${VOL_NAME}", f"{service_name}-data") \ - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") \ - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json(path, { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": "", - "password": "", - "port": mongo_port, - "host": "localhost", - "generated_id": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]") - - + if db_variant == "with-auth": + mongo_port = get_free_port() + db_user = "admin" + db_pass = secrets.token_hex(8) + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = ( + AGENT_MONGODB_AUTH_SNIPPET.replace( + "${SERVICE_NAME}", service_name + ) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "mongodb", + "username": db_user, + "password": db_pass, + "port": mongo_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]" + ) + else: + mongo_port = get_free_port() + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + + snippet = ( + AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + ) + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": "mongodb", + "username": "", + "password": "", + "port": mongo_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print( + f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]" + ) if volumes_list: for vol in volumes_list: @@ -233,16 +378,72 @@ def agent( final_compose = raw_template.replace("{{EXTRA_SERVICES}}", extra_services) final_compose = final_compose.replace("{{EXTRA_VOLUMES}}", extra_volumes) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) + vols_str = "\n".join([f" - {v}" for v in app_volumes]) + final_compose = final_compose.replace( + " - ./databases.json:/config/config.json", vols_str + ) + + summary = Table(show_header=False, box=None, padding=(0, 2)) + summary.add_column("Property", style="bold cyan") + summary.add_column("Value", style="white") + + summary.add_row("Agent Name", name) + summary.add_row("Project ID", project_name) + summary.add_row("Path", str(path)) + summary.add_row("Edge Key", f"{key[:10]}...{key[-10:]}" if len(key) > 20 else key) + summary.add_row("Timezone", tz) + summary.add_row("Polling", f"{polling}s") + summary.add_row("Environment", env) + + db_config = load_db_config(path) + dbs = db_config.get("databases", []) + if dbs: + db_details = [] + for db in dbs: + if db.get("type") == "sqlite": + db_details.append(f"• {db['name']} (sqlite: {db['database']})") + else: + db_details.append( + f"• {db['name']} ({db['type']} on port {db.get('port', 'N/A')})" + ) + + summary.add_row("Databases", "\n".join(db_details)) + else: + summary.add_row("Databases", "[dim]None configured[/dim]") + + summary.add_row("Files to Create", "• docker-compose.yml\n• .env\n• databases.json") + + console.print("") + console.print( + Panel( + summary, + title="[bold white]PROPOSED CONFIGURATION[/bold white]", + border_style="bold blue", + expand=False, + ) + ) + console.print( + "[dim]The agent will be configured in the directory above and ready for deployment.[/dim]\n" + ) + + if not Confirm.ask( + "[bold]Apply this configuration and generate files?[/bold]", default=True + ): + console.print("[warning]Configuration cancelled.[/warning]") + raise typer.Exit() + write_file(path / "docker-compose.yml", final_compose) write_env_file(path, env_vars) - console.print(Panel(f"[bold white]AGENT READY: {name}[/bold white]", style="bold #5f00d7")) + console.print( + Panel(f"[bold white]AGENT READY: {name}[/bold white]", style="bold #5f00d7") + ) if start or Confirm.ask("Start agent now?", default=False): - with console.status("[bold magenta]Starting...[/bold magenta]", spinner="earth"): + status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" + with console.status(status_msg, spinner="earth"): run_compose(path, ["up", "-d"]) console.print(f"[bold green]✔ Agent {name} is running[/bold green]") else: diff --git a/commands/common.py b/commands/common.py index baa5ae1..a456844 100644 --- a/commands/common.py +++ b/commands/common.py @@ -3,27 +3,30 @@ import shutil from pathlib import Path from rich.prompt import Confirm -from core.utils import console, validate_work_dir +from core.utils import console, validate_work_dir, get_random_hint from core.docker import run_compose def start(path: Path = typer.Argument(..., help="Path to component folder")): path = path.resolve() validate_work_dir(path) - with console.status(f"[bold magenta]Starting {path.name}...[/bold magenta]"): + status_msg = f"[bold magenta]Starting {path.name}...[/bold magenta]\n{get_random_hint()}" + with console.status(status_msg): run_compose(path, ["up", "-d"]) console.print("[success]✔ Started[/success]") def stop(path: Path = typer.Argument(..., help="Path to component folder")): path = path.resolve() validate_work_dir(path) - with console.status(f"[bold magenta]Stopping {path.name}...[/bold magenta]"): + status_msg = f"[bold magenta]Stopping {path.name}...[/bold magenta]\n{get_random_hint()}" + with console.status(status_msg): run_compose(path, ["stop"]) console.print("[success]✔ Stopped[/success]") def restart(path: Path = typer.Argument(..., help="Path to component folder")): path = path.resolve() validate_work_dir(path) - with console.status(f"[bold magenta]Restarting {path.name}...[/bold magenta]"): + status_msg = f"[bold magenta]Restarting {path.name}...[/bold magenta]\n{get_random_hint()}" + with console.status(status_msg): run_compose(path, ["restart"]) console.print("[success]✔ Restarted[/success]") @@ -54,7 +57,8 @@ def uninstall( if not Confirm.ask("Are you sure?"): raise typer.Exit() - with console.status(f"[bold red]Uninstalling...[/bold red]"): + status_msg = f"[bold red]Uninstalling...[/bold red]\n{get_random_hint()}" + with console.status(status_msg): run_compose(path, ["down", "-v"]) try: shutil.rmtree(path) diff --git a/commands/config.py b/commands/config.py index 4f7a4e9..7660f52 100644 --- a/commands/config.py +++ b/commands/config.py @@ -1,21 +1,26 @@ import typer + +from core.config import get_config_value, set_config_value from core.utils import console -from core.config import set_config_value, get_config_value app = typer.Typer(help="Manage global CLI configuration.") + @app.command() def channel( - name: str = typer.Argument(..., help="Update channel name (stable or beta)") + name: str = typer.Argument(..., help="Update channel name (stable or beta)"), ): name = name.lower() if name not in ["stable", "beta"]: - console.print("[danger]✖ Invalid channel. Choose either 'stable' or 'beta'.[/danger]") + console.print( + "[danger]✖ Invalid channel. Choose either 'stable' or 'beta'.[/danger]" + ) raise typer.Exit(1) - + set_config_value("update_channel", name) console.print(f"[success]✔ Update channel set to: [bold]{name}[/bold][/success]") + @app.command() def show(): channel = get_config_value("update_channel", "auto (based on current version)") diff --git a/commands/dashboard.py b/commands/dashboard.py index 0245e7e..4ddd863 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,17 +1,28 @@ -import typer +import re import secrets from pathlib import Path + +import typer from rich.panel import Panel -from rich.prompt import Confirm -from core.utils import console, print_banner, check_system, get_free_port -from core.config import write_file, write_env_file +from rich.prompt import Confirm, IntPrompt, Prompt +from rich.table import Table + +from core.config import write_env_file, write_file from core.docker import run_compose from core.network import fetch_template +from core.utils import ( + check_system, + console, + get_free_port, + get_random_hint, + print_banner, +) + def dashboard( name: str = typer.Argument(..., help="Name of the dashboard (creates a folder)"), port: str = typer.Option("8887", help="Web Port"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately") + start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), ): print_banner() check_system() @@ -21,40 +32,130 @@ def dashboard( console.print(f"[warning]Directory '{name}' already exists.[/warning]") if not Confirm.ask("Overwrite?"): raise typer.Exit() - + path.mkdir(parents=True, exist_ok=True) project_name = name.lower().replace(" ", "-") - + raw_template = fetch_template("dashboard.yml") - + auth_secret = secrets.token_hex(32) base_url = f"http://localhost:{port}" - pg_port = get_free_port() - + env_vars = { "HOST_PORT": port, - "POSTGRES_DB": "portabase", - "POSTGRES_USER": "portabase", - "POSTGRES_PASSWORD": secrets.token_hex(16), - "POSTGRES_HOST": "db", - "DATABASE_URL": f"postgresql://portabase:PWD@db:5432/portabase?schema=public", "PROJECT_SECRET": auth_secret, "PROJECT_URL": base_url, "PROJECT_NAME": project_name, - "PG_PORT": str(pg_port) } - env_vars["DATABASE_URL"] = env_vars["DATABASE_URL"].replace("PWD", env_vars["POSTGRES_PASSWORD"]) - final_compose = raw_template.replace("${PROJECT_NAME}", project_name) - + mode = Prompt.ask( + "Database Setup", choices=["internal", "external"], default="internal" + ) + + if mode == "internal": + pg_port = get_free_port() + pg_pass = secrets.token_hex(16) + env_vars.update( + { + "POSTGRES_DB": "portabase", + "POSTGRES_USER": "portabase", + "POSTGRES_PASSWORD": pg_pass, + "POSTGRES_HOST": "db", + "DATABASE_URL": f"postgresql://portabase:{pg_pass}@db:5432/portabase?schema=public", + "PG_PORT": str(pg_port), + } + ) + final_compose = raw_template.replace("${PROJECT_NAME}", project_name) + else: + console.print("[info]External Database Configuration[/info]") + db_host = Prompt.ask("Host", default="localhost") + db_port = IntPrompt.ask("Port", default=5432) + db_name = Prompt.ask("Database Name", default="portabase") + db_user = Prompt.ask("Username") + db_pass = Prompt.ask("Password", password=True) + + env_vars.update( + { + "POSTGRES_DB": db_name, + "POSTGRES_USER": db_user, + "POSTGRES_PASSWORD": db_pass, + "POSTGRES_HOST": db_host, + "DATABASE_URL": f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}?schema=public", + "PG_PORT": str(db_port), + } + ) + + final_compose = re.sub( + r"[ ]{8}depends_on:.*?service_healthy\n", "", raw_template, flags=re.DOTALL + ) + final_compose = re.sub( + r"[ ]{4}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL + ) + final_compose = re.sub(r"[ ]{4}postgres-data:\n", "", final_compose) + final_compose = final_compose.replace("${PROJECT_NAME}", project_name) + + summary = Table(show_header=False, box=None, padding=(0, 2)) + summary.add_column("Property", style="bold cyan") + summary.add_column("Value", style="white") + + summary.add_row("Dashboard Name", name) + summary.add_row("Path", str(path)) + summary.add_row("Access URL", f"[bold green]http://localhost:{port}[/bold green]") + summary.add_row( + "Database Setup", + "All-in-one (Internal Docker DB)" + if mode == "internal" + else "Custom (External Database)", + ) + + if mode == "internal": + summary.add_row("Internal Port", env_vars["PG_PORT"]) + else: + summary.add_row("DB Host", env_vars["POSTGRES_HOST"]) + summary.add_row("DB Name", env_vars["POSTGRES_DB"]) + masked_url = re.sub(r":.*?@", ":****@", env_vars["DATABASE_URL"]) + summary.add_row("Connection URL", f"[dim]{masked_url}[/dim]") + + summary.add_row("Files to Create", "• docker-compose.yml\n• .env") + + console.print("") + console.print( + Panel( + summary, + title="[bold white]PROPOSED CONFIGURATION[/bold white]", + border_style="bold blue", + expand=False, + ) + ) + console.print( + "[dim]The dashboard will be set up with the parameters above.[/dim]\n" + ) + + if not Confirm.ask( + "[bold]Apply this configuration and generate files?[/bold]", default=True + ): + console.print("[warning]Configuration cancelled.[/warning]") + raise typer.Exit() + write_file(path / "docker-compose.yml", final_compose) write_env_file(path, env_vars) - - console.print(Panel(f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]\n[dim]DB Port: {pg_port}[/dim]", style="bold #5f00d7")) + + db_info = ( + f"\n[dim]DB Port: {env_vars.get('PG_PORT')}[/dim]" + if mode == "internal" + else f"\n[dim]External DB: {env_vars.get('POSTGRES_HOST')}[/dim]" + ) + console.print( + Panel( + f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]{db_info}", + style="bold #5f00d7", + ) + ) if start or Confirm.ask("Start dashboard now?", default=False): - with console.status("[bold magenta]Starting...[/bold magenta]", spinner="earth"): + status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" + with console.status(status_msg, spinner="earth"): run_compose(path, ["up", "-d"]) console.print(f"[bold green]✔ Live at: http://localhost:{port}[/bold green]") else: - console.print(f"[info]Run: portabase start {name}[/info]") \ No newline at end of file + console.print(f"[info]Run: portabase start {name}[/info]") diff --git a/commands/db.py b/commands/db.py index 4d2d643..3ef9b8e 100644 --- a/commands/db.py +++ b/commands/db.py @@ -1,22 +1,32 @@ -import typer +import secrets import uuid from pathlib import Path -from rich.table import Table + +import typer from rich.panel import Panel -from rich.prompt import Prompt, IntPrompt -from core.utils import console, validate_work_dir -from core.config import load_db_config, save_db_config, add_db_to_json +from rich.prompt import IntPrompt, Prompt +from rich.table import Table + +from core.config import add_db_to_json, load_db_config, save_db_config, write_env_file +from core.utils import console, get_free_port, validate_work_dir +from templates.compose import ( + AGENT_MARIADB_SNIPPET, + AGENT_MONGODB_AUTH_SNIPPET, + AGENT_MONGODB_SNIPPET, + AGENT_POSTGRES_SNIPPET, +) app = typer.Typer(help="Manage databases configuration.") + @app.command("list") def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): path = Path(name).resolve() validate_work_dir(path) - + config = load_db_config(path) dbs = config.get("databases", []) - + if not dbs: console.print("[warning]No databases configured.[/warning]") return @@ -30,66 +40,282 @@ def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): table.add_column("ID", style="dim") for db in dbs: + db_type = db.get("type", "N/A") + host_port = ( + "Local File" + if db_type == "sqlite" + else f"{db.get('host', 'N/A')}:{db.get('port', 'N/A')}" + ) + username = "N/A" if db_type == "sqlite" else db.get("username", "N/A") + table.add_row( db.get("name", "N/A"), db.get("database", db.get("name", "N/A")), - db.get("type", "N/A"), - f"{db.get('host', 'N/A')}:{db.get('port', 'N/A')}", - db.get("username", "N/A"), - db.get("generated_id", "")[:8] + "..." + db_type, + host_port, + username, + db.get("generated_id", "")[:8] + "...", ) console.print(table) + @app.command("add") def add_db(name: str = typer.Argument(..., help="Name of the agent")): path = Path(name).resolve() validate_work_dir(path) - console.print(Panel("Add External Database Connection", style="bold blue")) - - db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb"], default="postgresql") - friendly_name = Prompt.ask("Display Name", default="External DB") - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask("Port", default=5432 if db_type == "postgresql" else 3306) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()) - } - - add_db_to_json(path, entry) + console.print(Panel("Add Database to Agent", style="bold blue")) + + mode = Prompt.ask( + "Configuration Mode", choices=["new", "existing"], default="existing" + ) + category = Prompt.ask("Category", choices=["SQL", "NoSQL"], default="SQL") + + if mode == "existing": + if category == "SQL": + db_type = Prompt.ask( + "Type", + choices=["postgresql", "mysql", "mariadb", "sqlite"], + default="postgresql", + ) + else: + db_type = Prompt.ask("Type", choices=["mongodb"], default="mongodb") + + friendly_name = Prompt.ask("Display Name", default="External DB") + + if db_type == "sqlite": + db_name = Prompt.ask("Database Path (e.g. /data/db.sqlite)") + entry = { + "name": friendly_name, + "database": db_name, + "type": db_type, + "generated_id": str(uuid.uuid4()), + } + else: + db_name = Prompt.ask("Database Name") + host = Prompt.ask("Host", default="localhost") + port = IntPrompt.ask( + "Port", + default=5432 + if db_type == "postgresql" + else (3306 if db_type in ["mysql", "mariadb"] else 27017), + ) + user = Prompt.ask("Username") + password = Prompt.ask("Password", password=True) + + entry = { + "name": friendly_name, + "database": db_name, + "type": db_type, + "username": user, + "password": password, + "port": port, + "host": host, + "generated_id": str(uuid.uuid4()), + } + + add_db_to_json(path, entry) + else: + if category == "SQL": + db_engine = Prompt.ask( + "Engine", + choices=["postgresql", "mysql", "mariadb", "sqlite"], + default="postgresql", + ) + else: + db_engine = Prompt.ask("Engine", choices=["mongodb"], default="mongodb") + db_variant = Prompt.ask( + "Type", choices=["standard", "with-auth"], default="standard" + ) + + env_vars = {} + snippet = "" + service_name = "" + db_name = "" + db_user = "" + db_pass = "" + db_port = 0 + + if db_engine == "sqlite": + db_name = Prompt.ask("Database Name", default="local") + if not db_name.endswith(".sqlite"): + db_name += ".sqlite" + + compose_path = path / "docker-compose.yml" + if compose_path.exists(): + with open(compose_path, "r") as f: + lines = f.readlines() + + new_lines = [] + in_app_service = False + in_volumes = False + for line in lines: + new_lines.append(line) + if "app:" in line: + in_app_service = True + if in_app_service and "volumes:" in line: + in_volumes = True + if in_volumes and "- ./databases.json" in line: + new_lines.append(f" - ./{db_name}:/config/{db_name}\n") + in_volumes = False + in_app_service = False + + with open(compose_path, "w") as f: + f.writelines(new_lines) + + add_db_to_json( + path, + { + "name": db_name, + "database": f"/config/{db_name}", + "type": "sqlite", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") + + elif db_engine == "postgresql": + db_port = get_free_port() + db_user = "admin" + db_pass = secrets.token_hex(8) + db_name = f"pg_{secrets.token_hex(4)}" + service_name = f"db-pg-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + elif db_engine in ["mysql", "mariadb"]: + db_port = get_free_port() + db_user = "admin" + db_pass = secrets.token_hex(8) + db_name = f"mysql_{secrets.token_hex(4)}" + service_name = f"db-mariadb-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + + elif db_engine == "mongodb": + db_port = get_free_port() + db_name = f"mongo_{secrets.token_hex(4)}" + if db_variant == "with-auth": + db_user = "admin" + db_pass = secrets.token_hex(8) + service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + snippet = ( + AGENT_MONGODB_AUTH_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + .replace("${USER}", f"${{{var_prefix}_USER}}") + .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") + ) + else: + service_name = f"db-mongo-{secrets.token_hex(2)}" + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(db_port) + env_vars[f"{var_prefix}_DB"] = db_name + snippet = ( + AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) + .replace("${PORT}", f"${{{var_prefix}_PORT}}") + .replace("${VOL_NAME}", f"{service_name}-data") + .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") + ) + + compose_path = path / "docker-compose.yml" + if compose_path.exists(): + with open(compose_path, "r") as f: + content = f.read() + + insert_pos = content.find("networks:") + if insert_pos == -1: + insert_pos = len(content) + + new_content = content[:insert_pos] + snippet + "\n" + content[insert_pos:] + + vol_snippet = f" {service_name}-data:\n" + vol_pos = new_content.find("volumes:") + if vol_pos != -1: + end_of_volumes = new_content.find("networks:", vol_pos) + if end_of_volumes == -1: + end_of_volumes = len(new_content) + new_content = ( + new_content[:end_of_volumes] + + vol_snippet + + new_content[end_of_volumes:] + ) + else: + new_content += f"\nvolumes:\n{vol_snippet}" + + with open(compose_path, "w") as f: + f.write(new_content) + + write_env_file(path, env_vars) + add_db_to_json( + path, + { + "name": db_name, + "database": db_name, + "type": db_engine, + "username": db_user, + "password": db_pass, + "port": db_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()), + }, + ) + console.print("[success]✔ Database added to configuration.[/success]") - console.print("[info]Restart the agent to apply changes: [/info]" + f"portabase restart {name}") + console.print( + "[info]Restart the agent to apply changes: [/info]" + + f"portabase restart {name}" + ) + @app.command("remove") def remove_db(name: str = typer.Argument(..., help="Name of the agent")): path = Path(name).resolve() validate_work_dir(path) - + config = load_db_config(path) dbs = config.get("databases", []) - + if not dbs: console.print("[warning]No databases to remove.[/warning]") return options = [f"{db['name']} ({db['type']})" for db in dbs] choice = Prompt.ask("Which database to remove?", choices=options) - + index = options.index(choice) removed = dbs.pop(index) - + config["databases"] = dbs save_db_config(path, config) - + console.print(f"[success]✔ Removed {removed['name']}[/success]") - console.print("[info]Restart the agent to apply changes.[/info]") \ No newline at end of file + console.print("[info]Restart the agent to apply changes.[/info]") diff --git a/core/network.py b/core/network.py index fcfe850..9eeeb84 100644 --- a/core/network.py +++ b/core/network.py @@ -2,7 +2,7 @@ import typer from rich.console import Console from core.config import TEMPLATE_BASE_URL -from core.utils import current_version +from core.utils import current_version, get_random_hint console = Console() @@ -11,7 +11,8 @@ def fetch_template(filename: str) -> str: url = f"{TEMPLATE_BASE_URL}/{version if version != 'unknown' else 'latest'}/{filename}" try: - with console.status(f"[dim]Fetching template from {url}...[/dim]"): + status_msg = f"[dim]Fetching template...[/dim]\n{get_random_hint()}" + with console.status(status_msg): response = requests.get(url, timeout=10) if response.status_code in [403, 404] and version != "unknown": url = f"{TEMPLATE_BASE_URL}/latest/{filename}" diff --git a/core/updater.py b/core/updater.py index 5236981..be187fe 100644 --- a/core/updater.py +++ b/core/updater.py @@ -1,38 +1,44 @@ -import requests -import subprocess -import time import json import os import platform -import sys import shutil -import typer +import subprocess +import sys +import tempfile +import time from pathlib import Path -from core.utils import current_version, console + +import requests +import typer + from core.config import get_config_value +from core.utils import console, current_version, get_random_hint GITHUB_REPO = "Portabase/cli" GITHUB_API_BASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" CACHE_FILE = Path.home() / ".portabase" / "update_cache.json" + def is_prerelease(version: str) -> bool: v = version.lower() - return any(x in v for x in ['a', 'b', 'rc', 'alpha', 'beta']) + return any(x in v for x in ["a", "b", "rc", "alpha", "beta"]) + def get_platform_info(): system = platform.system().lower() if system == "darwin": system = "macos" machine = platform.machine().lower() - + arch = "amd64" if machine in ["arm64", "aarch64"]: arch = "arm64" elif machine in ["x86_64", "amd64"]: arch = "amd64" - + return system, arch + def get_latest_release_data(pre=False): try: if not pre: @@ -47,20 +53,25 @@ def get_latest_release_data(pre=False): except Exception: return None + def check_for_updates(force=False): - if not force and not getattr(sys, 'frozen', False) and platform.system().lower() != "windows": + if ( + not force + and not getattr(sys, "frozen", False) + and platform.system().lower() != "windows" + ): return None current = current_version() if current == "unknown": return None - + channel = get_config_value("update_channel") if channel: - include_pre = (channel == "beta") + include_pre = channel == "beta" else: include_pre = is_prerelease(current) - + latest_tag = None try: @@ -76,10 +87,12 @@ def check_for_updates(force=False): if latest_tag is None: data = get_latest_release_data(pre=include_pre) if data: - latest_tag = data.get("tag_name", "").lstrip('v') + latest_tag = data.get("tag_name", "").lstrip("v") try: with open(CACHE_FILE, "w") as f: - json.dump({"last_check": time.time(), "latest_version": latest_tag}, f) + json.dump( + {"last_check": time.time(), "latest_version": latest_tag}, f + ) except Exception: pass @@ -87,41 +100,56 @@ def check_for_updates(force=False): return None if latest_tag != current: - console.print(f"\n[warning]⚠ A new version of Portabase CLI is available: [bold]{latest_tag}[/bold] (current: {current})[/warning]") + console.print( + f"\n[warning]⚠ A new version of Portabase CLI is available: [bold]{latest_tag}[/bold] (current: {current})[/warning]" + ) console.print("[info]Run [bold]portabase update[/bold] to update.[/info]\n") return latest_tag return None + def update_cli(): - if not getattr(sys, 'frozen', False) and platform.system().lower() != "windows": - console.print("[warning]⚠ The update command is only available for the binary version of Portabase CLI.[/warning]") - console.print("[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]") + if not getattr(sys, "frozen", False) and platform.system().lower() != "windows": + console.print( + "[warning]⚠ The update command is only available for the binary version of Portabase CLI.[/warning]" + ) + console.print( + "[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]" + ) return current = current_version() - + channel = get_config_value("update_channel") if channel: - pre = (channel == "beta") + pre = channel == "beta" else: pre = is_prerelease(current) if current != "unknown" else False data = get_latest_release_data(pre=pre) if not data: - console.print("[danger]✖ Could not fetch latest release data from GitHub.[/danger]") + console.print( + "[danger]✖ Could not fetch latest release data from GitHub.[/danger]" + ) return - latest_tag = data.get("tag_name", "").lstrip('v') - + latest_tag = data.get("tag_name", "").lstrip("v") + if latest_tag == current: - console.print(f"[success]✔ Portabase CLI is already up to date ({current}).[/success]") + console.print( + f"[success]✔ Portabase CLI is already up to date ({current}).[/success]" + ) return try: - if latest_tag < current and not (is_prerelease(current) and not is_prerelease(latest_tag)): - console.print(f"[warning]⚠ Current version ({current}) appears to be older than the latest remote version ({latest_tag}).[/warning]") - if not typer.confirm("Do you want to continue with the update ?"): - return + if latest_tag < current and not ( + is_prerelease(current) and not is_prerelease(latest_tag) + ): + console.print( + f"[warning]⚠ Current version ({current}) appears to be older than the latest remote version ({latest_tag}).[/warning]" + ) + if not typer.confirm("Do you want to continue with the update ?"): + return except Exception: pass @@ -129,102 +157,135 @@ def update_cli(): asset_name = f"portabase_{system}_{arch}" if system == "windows": asset_name += ".exe" - + asset = next((a for a in data.get("assets", []) if a["name"] == asset_name), None) - + if not asset: - console.print(f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]") + console.print( + f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]" + ) available_assets = [a["name"] for a in data.get("assets", [])] console.print(f"[info]Target asset name: {asset_name}[/info]") console.print(f"[info]Available assets: {', '.join(available_assets)}[/info]") return - console.print(f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]") - + console.print( + f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]" + ) + try: if system == "windows": - default_bin_path = Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" + default_bin_path = ( + Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" + ) else: default_bin_path = Path("/usr/local/bin/portabase") - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): current_exe = Path(sys.executable) else: if default_bin_path.exists(): current_exe = default_bin_path else: - current_exe = Path.home() / ".local" / "bin" / ("portabase" if system != "windows" else "portabase.exe") - + current_exe = ( + Path.home() + / ".local" + / "bin" + / ("portabase" if system != "windows" else "portabase.exe") + ) + console.print(f"[info]Target installation path: {current_exe}[/info]") download_url = asset["browser_download_url"] - import tempfile - # Create a temporary file that won't have permission issues or conflicts fd, temp_path = tempfile.mkstemp(prefix="portabase_update_") temp_file = Path(temp_path) os.close(fd) - + try: response = requests.get(download_url, stream=True, timeout=15) response.raise_for_status() - total_size = int(response.headers.get('content-length', 0)) - - from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn - + total_size = int(response.headers.get("content-length", 0)) + + from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + SpinnerColumn, + TextColumn, + TransferSpeedColumn, + ) + with Progress( SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), + TextColumn( + "[progress.description]{task.description}\n[hint]" + + get_random_hint() + + "[/hint]" + ), BarColumn(), DownloadColumn(), TransferSpeedColumn(), - console=console + console=console, ) as progress: - task = progress.add_task(f"Downloading {asset_name}...", total=total_size) + task = progress.add_task( + f"Downloading {asset_name}...", total=total_size + ) with open(temp_file, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) progress.update(task, advance=len(chunk)) except Exception as e: - if temp_file.exists(): temp_file.unlink() + if temp_file.exists(): + temp_file.unlink() raise e - + if system != "windows": temp_file.chmod(0o755) if system == "windows": if current_exe.exists(): old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): old_exe.unlink() + if old_exe.exists(): + old_exe.unlink() current_exe.rename(old_exe) temp_file.rename(current_exe) else: - need_sudo = not os.access(current_exe.parent, os.W_OK) or (current_exe.exists() and not os.access(current_exe, os.W_OK)) - + need_sudo = not os.access(current_exe.parent, os.W_OK) or ( + current_exe.exists() and not os.access(current_exe, os.W_OK) + ) + if need_sudo: - console.print("[info]Permissions required to install to /usr/local/bin. Using sudo...[/info]") + console.print( + "[info]Permissions required to install to /usr/local/bin. Using sudo...[/info]" + ) if current_exe.exists(): - subprocess.run(["sudo", "mv", str(current_exe), f"{current_exe}.old"], check=False) - subprocess.run(["sudo", "mv", str(temp_file), str(current_exe)], check=True) + subprocess.run( + ["sudo", "mv", str(current_exe), f"{current_exe}.old"], + check=False, + ) + subprocess.run( + ["sudo", "mv", str(temp_file), str(current_exe)], check=True + ) subprocess.run(["sudo", "chmod", "+x", str(current_exe)], check=True) else: if current_exe.exists(): old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): old_exe.unlink() + if old_exe.exists(): + old_exe.unlink() current_exe.rename(old_exe) current_exe.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(temp_file), str(current_exe)) - + try: console.print(f"[success]✔ Successfully updated to {latest_tag}![/success]") except Exception: - # Fallback to plain print if rich/PyInstaller fails to load modules after update print(f"Successfully updated to {latest_tag}!") - + except Exception as e: try: console.print(f"[danger]✖ An error occurred during update: {e}[/danger]") except Exception: print(f"An error occurred during update: {e}") - if 'temp_file' in locals() and temp_file.exists(): - temp_file.unlink() \ No newline at end of file + if "temp_file" in locals() and temp_file.exists(): + temp_file.unlink() diff --git a/core/utils.py b/core/utils.py index 62bd19f..bde0210 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,20 +1,63 @@ -import socket +import base64 +import binascii +import json +import platform +import random import shutil -import typer +import socket +import subprocess +import time from pathlib import Path -from rich.console import Console, Theme + +import typer from rich.align import Align -import subprocess +from rich.console import Console, Theme +from rich.prompt import Confirm + +custom_theme = Theme( + { + "info": "dim cyan", + "warning": "magenta", + "danger": "bold red", + "success": "bold green", + "title": "bold white on #5f00d7", + "key": "bold #ff6600", + "value": "white", + "hint": "italic dim white", + } +) + +HINTS = [ + "The Edge Key contains the connection details for dashboard and agent communication.", + "Portabase uses Docker Compose to isolate your databases.", + "You can list all configured databases using 'portabase db list '.", + "Running 'portabase stop' will gracefully shut down your containers.", + "The agent polls the github for configuration updates.", + "Logs can be viewed in real-time with 'portabase logs '.", + "Custom environment variables can be added to the generated .env file.", + "Need to update? Use 'portabase update' to get the latest version.", + "You can add multiple databases to a single agent during setup.", + "Portabase Dashboard provides a web interface to manage your infrastructure.", + "Is Docker not running? The CLI will offer to start it for you!", + "All configurations are stored locally in the component's folder.", + "The 'portabase restart' command is useful after manual .env modifications.", + "Portabase is open-source! Check our GitHub to contribute.", + "Using the --start flag with 'agent' or 'dashboard' skips the final prompt.", + "Internal databases are automatically backed up when using volumes.", + "The dashboard requires a PostgreSQL database to store its own data.", + "You can change the update channel to 'beta' in the config for early features.", + "Portabase network ensures secure communication between your containers.", + "Lost your Edge Key? You can find it in the dashboard.", + "The 'portabase uninstall' command safely removes containers and their data.", + "Use 'portabase --version' to check your current installation details.", + "The 'databases.json' file keeps track of all managed database instances.", +] + + +def get_random_hint(): + return f"[hint]{random.choice(HINTS)}[/hint]" + -custom_theme = Theme({ - "info": "dim cyan", - "warning": "magenta", - "danger": "bold red", - "success": "bold green", - "title": "bold white on #5f00d7", - "key": "bold #ff6600", - "value": "white" -}) console = Console(theme=custom_theme) BANNER = """ @@ -23,49 +66,112 @@ [dim]Deploy your infrastructure anywhere.[/dim] """ + def print_banner(): console.print(Align.center(BANNER)) + console.print(Align.center(get_random_hint() + "\n")) + def get_free_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) return s.getsockname()[1] + +def start_docker(): + """Attempts to start the Docker daemon based on the OS.""" + os_type = platform.system() + + try: + if os_type == "Linux": + subprocess.run(["sudo", "systemctl", "start", "docker"], check=True) + elif os_type == "Darwin": + subprocess.run(["open", "--background", "-a", "Docker"], check=True) + elif os_type == "Windows": + subprocess.run(["start", "docker"], shell=True, check=True) + + console.print("[info]Waiting for Docker to start...[/info]") + for _ in range(10): + try: + subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + console.print("[success]✔ Docker started successfully.[/success]") + return True + except: + time.sleep(2) + except Exception as e: + console.print(f"[danger]✖ Failed to start Docker:[/danger] {e}") + + return False + + def check_system(): docker_path = shutil.which("docker") - + if docker_path is None: console.print("[danger]✖ Docker not found (binary missing).[/danger]") raise typer.Exit(1) try: subprocess.run( - [docker_path, "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True + [docker_path, "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, ) except subprocess.CalledProcessError: - console.print("[danger]✖ Docker is installed but the Daemon is not running.[/danger]") - console.print("[dim]Please start Docker Desktop or the docker service.[/dim]") + console.print( + "[warning]⚠ Docker is installed but the Daemon is not running.[/warning]" + ) + if Confirm.ask("Do you want to try starting Docker?"): + if start_docker(): + return + + console.print("[danger]✖ Docker is required to continue.[/danger]") raise typer.Exit(1) except Exception as e: console.print(f"[danger]✖ Critical Error executing Docker:[/danger] {e}") raise typer.Exit(1) + def validate_work_dir(path: Path): if not (path / "docker-compose.yml").exists(): console.print(f"[danger]No Portabase configuration found in: {path}[/danger]") raise typer.Exit(1) return path -def current_version() -> str: + +def validate_edge_key(key: str) -> bool: + """Validates the integrity of the EDGE_KEY (Base64 or JSON).""" + try: + try: + decoded_bytes = base64.b64decode(key, validate=True) + decoded_str = decoded_bytes.decode("utf-8") + data = json.loads(decoded_str) + except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError): + try: + data = json.loads(key) + except json.JSONDecodeError: + return False + + required_fields = ["serverUrl", "agentId", "masterKeyB64"] + return all(field in data for field in required_fields) + except Exception: + return False + + +def current_version() -> str: try: - import tomllib import sys + import tomllib from pathlib import Path - if getattr(sys, 'frozen', False): + + if getattr(sys, "frozen", False): base_path = Path(sys._MEIPASS) else: base_path = Path(__file__).parent.parent diff --git a/main.py b/main.py index f2a0afe..c054b64 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,21 @@ -import typer from typing import Optional -from commands import agent, dashboard, common, db, config -from core.utils import console, current_version + +import typer + +from commands import agent, common, config, dashboard, db from core.updater import check_for_updates, update_cli +from core.utils import console, current_version app = typer.Typer(no_args_is_help=True, add_completion=False) + def version_callback(value: bool): if value: console.print(f"Portabase CLI version: {current_version()}") check_for_updates(force=True) raise typer.Exit() + @app.callback() def main( ctx: typer.Context, @@ -26,10 +30,12 @@ def main( if ctx.invoked_subcommand != "update": check_for_updates() + @app.command() def update(): update_cli() + app.command()(agent.agent) app.command()(dashboard.dashboard) app.command()(common.start) diff --git a/release b/release index 1dbf212..09d0903 100755 --- a/release +++ b/release @@ -93,4 +93,4 @@ echo "Pushing changes and tags to remote..." git push git push origin "$VERSION" -echo "Successfully released $VERSION!" \ No newline at end of file +echo "Successfully released $VERSION!"