Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 337 additions & 136 deletions commands/agent.py

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 9 additions & 4 deletions commands/config.py
Original file line number Diff line number Diff line change
@@ -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)")
Expand Down
147 changes: 124 additions & 23 deletions commands/dashboard.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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),
}
Comment on lines +77 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the file
find . -name "dashboard.py" -path "*/commands/*" -type f

Repository: Portabase/cli

Length of output: 80


🏁 Script executed:

# Read the dashboard.py file around lines 77-85
if [ -f "commands/dashboard.py" ]; then
  wc -l commands/dashboard.py
  sed -n '70,95p' commands/dashboard.py
fi

Repository: Portabase/cli

Length of output: 1221


🏁 Script executed:

# Check for any existing imports related to URL encoding
rg "urllib|quote" commands/dashboard.py -A 2 -B 2

Repository: Portabase/cli

Length of output: 39


🏁 Script executed:

# Search for how DATABASE_URL is used elsewhere in the codebase
rg "DATABASE_URL" . --type py -B 2 -A 2

Repository: Portabase/cli

Length of output: 1186


🏁 Script executed:

# Check the context around the hardcoded DATABASE_URL to see if it's also part of this change
sed -n '50,70p' commands/dashboard.py

Repository: Portabase/cli

Length of output: 805


🏁 Script executed:

# Verify PostgreSQL URI encoding requirements through standard documentation
# PostgreSQL connection strings follow RFC 3986, which requires special characters to be percent-encoded
python3 <<'EOF'
# Demonstrate the issue with special characters in PostgreSQL DSN
import re
from urllib.parse import quote, urlparse, parse_qs

# Test DSN with special characters that break parsing
test_password = "p@ss:word#123"
broken_dsn = f"postgresql://user:{test_password}@localhost:5432/db?schema=public"

print("=== Testing PostgreSQL DSN Parsing ===")
print(f"Password with special chars: {test_password}")
print(f"Broken DSN: {broken_dsn}")

# Try to parse it
try:
    parsed = urlparse(broken_dsn)
    print(f"\nParsed netloc: {parsed.netloc}")
    print(f"Parsed hostname: {parsed.hostname}")
    print(f"Parsed port: {parsed.port}")
    print(f"Parsed password: {parsed.password}")
    print("\nNote: The @ in password breaks the parsing - password includes everything before the last @")
except Exception as e:
    print(f"Error: {e}")

print("\n=== Correct approach with percent-encoding ===")
encoded_user = quote("user", safe='')
encoded_pass = quote(test_password, safe='')
correct_dsn = f"postgresql://{encoded_user}:{encoded_pass}@localhost:5432/db?schema=public"
print(f"Encoded DSN: {correct_dsn}")

try:
    parsed = urlparse(correct_dsn)
    print(f"Parsed hostname: {parsed.hostname}")
    print(f"Parsed port: {parsed.port}")
    print(f"Parsed password: {parsed.password}")
    print("✓ Parsing works correctly with encoding")
except Exception as e:
    print(f"Error: {e}")
EOF

Repository: Portabase/cli

Length of output: 575


Percent-encode external DB credentials in DATABASE_URL.

Line 83 interpolates raw db_user and db_pass into the URI. Passwords containing @, :, /, or # will produce an invalid DSN even though the prompt accepted them. The special characters break URI parsing (e.g., @ is interpreted as the user/host delimiter). Percent-encode the credentials before composing the URL using urllib.parse.quote().

🔐 Minimal fix
+from urllib.parse import quote
...
-                "DATABASE_URL": f"postgresql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}?schema=public",
+                "DATABASE_URL": (
+                    f"postgresql://{quote(db_user, safe='')}:{quote(db_pass, safe='')}@"
+                    f"{db_host}:{db_port}/{db_name}?schema=public"
+                ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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),
}
env_vars.update(
{
"POSTGRES_DB": db_name,
"POSTGRES_USER": db_user,
"POSTGRES_PASSWORD": db_pass,
"POSTGRES_HOST": db_host,
"DATABASE_URL": (
f"postgresql://{quote(db_user, safe='')}:{quote(db_pass, safe='')}@"
f"{db_host}:{db_port}/{db_name}?schema=public"
),
"PG_PORT": str(db_port),
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@commands/dashboard.py` around lines 77 - 85, The DATABASE_URL construction in
env_vars.update currently interpolates raw db_user and db_pass (used in the
"DATABASE_URL" value) which can break DSN parsing for special characters; import
urllib.parse.quote and percent-encode db_user and db_pass (e.g., use
quote(db_user, safe='') and quote(db_pass, safe='')) before composing the
f-string so the DATABASE_URL uses the encoded credentials while leaving db_host,
db_port, and db_name unchanged; update the env_vars.update block that sets
"DATABASE_URL" to use the encoded_user and encoded_pass variables.

)

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]")
console.print(f"[info]Run: portabase start {name}[/info]")
Loading