mirror of
https://github.com/acedanger/shell.git
synced 2026-03-24 22:31:50 -07:00
Compare commits
2 Commits
e850221326
...
630dc31035
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630dc31035 | ||
|
|
618b979c0a |
@@ -4,9 +4,12 @@ A Python command-line application to manage Docker containers defined in subdire
|
||||
|
||||
## Features
|
||||
|
||||
- **Rich UI**: Beautiful terminal output using the `rich` library, including tables, panels, and colored status indicators.
|
||||
- **List**: View currently running containers across all your projects.
|
||||
- **Describe**: Show detailed information about containers in a specific project, including descriptions.
|
||||
- **Volumes**: List volumes used by a specific project.
|
||||
- **Diun Integration**: Automatically detects if [Diun](https://github.com/crazy-max/diun) is running and displays image versions and update availability directly in the list.
|
||||
- **Describe**: Show detailed information about containers in a specific project, including descriptions, ports, and a formatted table of volumes.
|
||||
- **Volumes**: List all volumes used by a specific project with source and destination details.
|
||||
- **Logs**: View logs for a project, with options to follow, tail, and **filter by specific container/service**.
|
||||
- **Stop**: Stop containers for a specific project or all projects.
|
||||
- **Update**: Pull the latest images and recreate containers (equivalent to `docker compose pull && docker compose up -d`).
|
||||
- **Restart**: Restart containers for a specific project or all projects.
|
||||
@@ -15,6 +18,10 @@ A Python command-line application to manage Docker containers defined in subdire
|
||||
|
||||
- Python 3
|
||||
- Docker and Docker Compose (plugin) installed.
|
||||
- The `rich` Python library:
|
||||
```bash
|
||||
pip install rich
|
||||
```
|
||||
- A `~/docker/` directory containing subdirectories for each of your projects.
|
||||
- Each project subdirectory must contain a `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, or `compose.yaml` file.
|
||||
|
||||
@@ -46,10 +53,34 @@ A Python command-line application to manage Docker containers defined in subdire
|
||||
Run the application using the command name you set up (e.g., `dm`).
|
||||
|
||||
### List Running Containers
|
||||
Displays a table with Project, Container Name, State, Image, Version, and Update Status.
|
||||
```bash
|
||||
dm list
|
||||
```
|
||||
|
||||
### Describe Project
|
||||
Show detailed info (Service, Image, State, Ports, Description, Volumes) for a project.
|
||||
```bash
|
||||
dm describe project_name
|
||||
```
|
||||
|
||||
### List Volumes
|
||||
Show a table of all volumes (Bind mounts and named volumes) for a project.
|
||||
```bash
|
||||
dm volumes project_name
|
||||
```
|
||||
|
||||
### View Logs
|
||||
View logs for a project.
|
||||
```bash
|
||||
dm logs project_name
|
||||
```
|
||||
**Options:**
|
||||
- Follow output: `dm logs project_name -f`
|
||||
- Tail specific number of lines: `dm logs project_name --tail 100`
|
||||
- **Filter by container**: `dm logs project_name container_name`
|
||||
- Example: `dm logs media gluetun --tail 50`
|
||||
|
||||
### Stop Containers
|
||||
Stop a specific project:
|
||||
```bash
|
||||
|
||||
@@ -37,9 +37,92 @@ import argparse
|
||||
import subprocess
|
||||
import json
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich import box
|
||||
|
||||
# Configuration
|
||||
DOCKER_ROOT = Path(os.path.expanduser("~/docker"))
|
||||
console = Console()
|
||||
|
||||
|
||||
def get_diun_container_name():
|
||||
"""Find the running diun container name."""
|
||||
# Try to find by image name first (most reliable if standard image is used)
|
||||
cmd = ["docker", "ps", "--filter", "ancestor=crazymax/diun", "--format", "{{.Names}}"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
return res.stdout.strip().split('\n')[0]
|
||||
|
||||
# Fallback to finding by name containing 'diun'
|
||||
cmd = ["docker", "ps", "--filter", "name=diun", "--format", "{{.Names}}"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
return res.stdout.strip().split('\n')[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_diun_info():
|
||||
"""Fetch image information from Diun."""
|
||||
diun_info = {}
|
||||
try:
|
||||
diun_container = get_diun_container_name()
|
||||
|
||||
if not diun_container:
|
||||
return diun_info
|
||||
|
||||
# Get diun image list
|
||||
cmd = ["docker", "exec", diun_container, "diun", "image", "list", "--raw"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if res.returncode != 0:
|
||||
return diun_info
|
||||
|
||||
data = json.loads(res.stdout)
|
||||
for img in data.get("images", []):
|
||||
name = img.get("name", "")
|
||||
latest = img.get("latest", {})
|
||||
digest = latest.get("digest", "")
|
||||
labels = latest.get("labels", {})
|
||||
|
||||
# Normalize name for matching
|
||||
# Remove docker.io/ prefix
|
||||
norm_name = name
|
||||
if norm_name.startswith("docker.io/"):
|
||||
norm_name = norm_name[10:]
|
||||
|
||||
# If no registry (no /), prepend library/
|
||||
if "/" not in norm_name:
|
||||
norm_name = f"library/{norm_name}"
|
||||
|
||||
diun_info[norm_name] = {
|
||||
"digest": digest,
|
||||
"labels": labels,
|
||||
"tag": latest.get("tag", ""),
|
||||
"original_name": name
|
||||
}
|
||||
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return diun_info
|
||||
|
||||
|
||||
def get_image_version(labels):
|
||||
"""Extract version from image labels."""
|
||||
version_keys = [
|
||||
"org.opencontainers.image.version",
|
||||
"version",
|
||||
"org.label-schema.version",
|
||||
"build_version"
|
||||
]
|
||||
for key in version_keys:
|
||||
if key in labels:
|
||||
return labels[key]
|
||||
return "latest"
|
||||
|
||||
|
||||
def get_projects():
|
||||
@@ -80,26 +163,88 @@ def run_command(cmd, cwd, capture_output=False):
|
||||
|
||||
def list_containers(projects):
|
||||
"""List running containers for all projects."""
|
||||
print(f"{'Project':<20} | {'Container Name':<40} | {'State':<10}")
|
||||
print("-" * 75)
|
||||
table = Table(title="Docker Containers", box=box.ROUNDED)
|
||||
table.add_column("Project", style="cyan", no_wrap=True)
|
||||
table.add_column("Container Name", style="green")
|
||||
table.add_column("State", style="yellow")
|
||||
table.add_column("Image", style="blue")
|
||||
table.add_column("Version", style="magenta")
|
||||
table.add_column("Update", style="red")
|
||||
|
||||
# Get Diun info once
|
||||
diun_info = get_diun_info()
|
||||
|
||||
found_any = False
|
||||
for name, path in sorted(projects.items()):
|
||||
# Get running container names
|
||||
cmd = ["docker", "compose", "ps", "--format",
|
||||
"{{.Names}}", "--filter", "status=running"]
|
||||
cmd = ["docker", "compose", "ps", "--format", "json"]
|
||||
res = run_command(cmd, path, capture_output=True)
|
||||
|
||||
if res and res.returncode == 0:
|
||||
containers = res.stdout.strip().split('\n')
|
||||
containers = [c for c in containers if c] # filter empty
|
||||
if containers:
|
||||
found_any = True
|
||||
for container in containers:
|
||||
print(f"{name:<20} | {container:<40} | Running")
|
||||
lines = res.stdout.strip().split('\n')
|
||||
for line in lines:
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
container = json.loads(line)
|
||||
c_name = container.get('Name', '')
|
||||
state = container.get('State', '')
|
||||
image = container.get('Image', '')
|
||||
|
||||
if not found_any:
|
||||
print("No running containers found in managed projects.")
|
||||
if not c_name:
|
||||
continue
|
||||
|
||||
found_any = True
|
||||
|
||||
# Get version and update status
|
||||
version = "unknown"
|
||||
update_status = ""
|
||||
|
||||
# Normalize image name for lookup
|
||||
norm_image = image
|
||||
if norm_image.startswith("docker.io/"):
|
||||
norm_image = norm_image[10:]
|
||||
if "/" not in norm_image:
|
||||
norm_image = f"library/{norm_image}"
|
||||
|
||||
# Check Diun info
|
||||
if norm_image in diun_info:
|
||||
info = diun_info[norm_image]
|
||||
# Try to get version from Diun labels first
|
||||
version = get_image_version(info.get("labels", {}))
|
||||
|
||||
# Check for updates
|
||||
inspect_digest_cmd = ["docker", "inspect", c_name, "--format", "{{index .RepoDigests 0}}"]
|
||||
inspect_digest_res = run_command(inspect_digest_cmd, path, capture_output=True)
|
||||
if inspect_digest_res and inspect_digest_res.returncode == 0:
|
||||
running_digest_full = inspect_digest_res.stdout.strip()
|
||||
# running_digest is like name@sha256:hash
|
||||
if "@" in running_digest_full:
|
||||
running_digest = running_digest_full.split("@")[1]
|
||||
latest_digest = info.get("digest", "")
|
||||
if latest_digest and running_digest != latest_digest:
|
||||
update_status = "Update Available"
|
||||
|
||||
# If version is still unknown, try to get from running container labels
|
||||
if version == "latest" or version == "unknown":
|
||||
inspect_cmd = ["docker", "inspect", c_name, "--format", "{{json .Config.Labels}}"]
|
||||
inspect_res = run_command(inspect_cmd, path, capture_output=True)
|
||||
if inspect_res and inspect_res.returncode == 0:
|
||||
try:
|
||||
labels = json.loads(inspect_res.stdout)
|
||||
version = get_image_version(labels)
|
||||
except:
|
||||
pass
|
||||
|
||||
table.add_row(name, c_name, state, image, version, update_status)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if found_any:
|
||||
console.print(table)
|
||||
else:
|
||||
console.print("[yellow]No running containers found in managed projects.[/yellow]")
|
||||
|
||||
|
||||
def is_project_running(path):
|
||||
@@ -117,11 +262,11 @@ def is_project_running(path):
|
||||
def describe_project(projects, target):
|
||||
"""Show detailed information about containers in a project."""
|
||||
if target not in projects:
|
||||
print(f"Error: Project '{target}' not found.")
|
||||
console.print(f"[red]Error: Project '{target}' not found.[/red]")
|
||||
return
|
||||
|
||||
path = projects[target]
|
||||
print(f"Describing project: {target}")
|
||||
console.print(Panel(f"[bold blue]Describing project: {target}[/bold blue]", expand=False))
|
||||
|
||||
cmd = ["docker", "compose", "ps", "--format", "json"]
|
||||
res = run_command(cmd, path, capture_output=True)
|
||||
@@ -136,79 +281,88 @@ def describe_project(projects, target):
|
||||
container = json.loads(line)
|
||||
found_any = True
|
||||
name = container.get('Name', 'N/A')
|
||||
print("-" * 60)
|
||||
print(f"Service: {container.get('Service', 'N/A')}")
|
||||
print(f"Container: {name}")
|
||||
print(f"Image: {container.get('Image', 'N/A')}")
|
||||
print(f"State: {container.get('State', 'N/A')}")
|
||||
print(f"Status: {container.get('Status', 'N/A')}")
|
||||
|
||||
# Create a table for this container
|
||||
table = Table(show_header=False, box=box.SIMPLE)
|
||||
table.add_column("Field", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row("Service", container.get('Service', 'N/A'))
|
||||
table.add_row("Container", name)
|
||||
table.add_row("Image", container.get('Image', 'N/A'))
|
||||
table.add_row("State", container.get('State', 'N/A'))
|
||||
table.add_row("Status", container.get('Status', 'N/A'))
|
||||
|
||||
ports = container.get('Ports', '')
|
||||
if ports:
|
||||
print(f"Ports: {ports}")
|
||||
table.add_row("Ports", ports)
|
||||
|
||||
# Get details via inspect for better parsing (Labels and Mounts)
|
||||
# Get details via inspect
|
||||
inspect_cmd = ["docker", "inspect", name, "--format", "{{json .}}"]
|
||||
inspect_res = run_command(inspect_cmd, path, capture_output=True)
|
||||
if inspect_res and inspect_res.returncode == 0:
|
||||
try:
|
||||
details = json.loads(inspect_res.stdout.strip())
|
||||
|
||||
# Description from labels
|
||||
# Description
|
||||
labels = details.get("Config", {}).get("Labels", {})
|
||||
description = labels.get("org.opencontainers.image.description")
|
||||
if description:
|
||||
print(f"Description: {description}")
|
||||
table.add_row("Description", description)
|
||||
|
||||
console.print(Panel(table, title=f"[bold]{name}[/bold]", border_style="green"))
|
||||
|
||||
# Volumes/Mounts
|
||||
mounts = details.get("Mounts", [])
|
||||
if mounts:
|
||||
print("Volumes:")
|
||||
print(f" {'Name/Type':<20} | {'Source (Local Path)':<50} | {'Destination'}")
|
||||
print(f" {'-'*20} | {'-'*50} | {'-'*20}")
|
||||
v_table = Table(title="Volumes", box=box.MINIMAL)
|
||||
v_table.add_column("Type", style="cyan")
|
||||
v_table.add_column("Source", style="yellow")
|
||||
v_table.add_column("Destination", style="green")
|
||||
|
||||
for mount in mounts:
|
||||
m_type = mount.get("Type")
|
||||
vol_name = mount.get("Name", "")
|
||||
source = mount.get("Source", "")
|
||||
dest = mount.get("Destination", "")
|
||||
v_table.add_row(m_type, source, dest)
|
||||
|
||||
# If it's a bind mount, use "Bind" as name, otherwise use volume name
|
||||
display_name = vol_name if vol_name else f"[{m_type}]"
|
||||
|
||||
# Truncate source if too long for cleaner display, or just let it wrap?
|
||||
# Let's just print it.
|
||||
print(f" {display_name:<20} | {source:<50} | {dest}")
|
||||
console.print(v_table)
|
||||
console.print("") # Spacing
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
console.print(Panel(table, title=f"[bold]{name}[/bold]", border_style="green"))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not found_any:
|
||||
print("No containers found for this project.")
|
||||
console.print("[yellow]No containers found for this project.[/yellow]")
|
||||
else:
|
||||
print("Failed to get container info.")
|
||||
console.print("[red]Failed to get container info.[/red]")
|
||||
|
||||
|
||||
def list_project_volumes(projects, target):
|
||||
"""List volumes used by containers in a project."""
|
||||
if target not in projects:
|
||||
print(f"Error: Project '{target}' not found.")
|
||||
console.print(f"[red]Error: Project '{target}' not found.[/red]")
|
||||
return
|
||||
|
||||
path = projects[target]
|
||||
print(f"Volumes for project: {target}")
|
||||
print("-" * 100)
|
||||
print(f"{'Service':<20} | {'Type':<10} | {'Name/Source':<40} | {'Destination'}")
|
||||
print("-" * 100)
|
||||
|
||||
table = Table(title=f"Volumes for project: {target}", box=box.ROUNDED)
|
||||
table.add_column("Service", style="cyan")
|
||||
table.add_column("Container", style="blue")
|
||||
table.add_column("Type", style="magenta")
|
||||
table.add_column("Source", style="yellow")
|
||||
table.add_column("Destination", style="green")
|
||||
|
||||
cmd = ["docker", "compose", "ps", "--format", "json"]
|
||||
res = run_command(cmd, path, capture_output=True)
|
||||
|
||||
if res and res.returncode == 0:
|
||||
lines = res.stdout.strip().split('\n')
|
||||
found_any = False
|
||||
for line in lines:
|
||||
if not line:
|
||||
continue
|
||||
@@ -217,7 +371,6 @@ def list_project_volumes(projects, target):
|
||||
name = container.get('Name', 'N/A')
|
||||
service = container.get('Service', 'N/A')
|
||||
|
||||
# Get details via inspect
|
||||
inspect_cmd = ["docker", "inspect", name, "--format", "{{json .Mounts}}"]
|
||||
inspect_res = run_command(inspect_cmd, path, capture_output=True)
|
||||
|
||||
@@ -225,32 +378,21 @@ def list_project_volumes(projects, target):
|
||||
try:
|
||||
mounts = json.loads(inspect_res.stdout.strip())
|
||||
if mounts:
|
||||
found_any = True
|
||||
for mount in mounts:
|
||||
m_type = mount.get("Type", "unknown")
|
||||
source = mount.get("Source", "")
|
||||
dest = mount.get("Destination", "")
|
||||
name_or_source = mount.get("Name", "")
|
||||
|
||||
if m_type == "bind":
|
||||
name_or_source = source
|
||||
elif not name_or_source:
|
||||
name_or_source = source
|
||||
|
||||
print(f"{service:<20} | {m_type:<10} | {name_or_source:<40} | {dest}")
|
||||
m_type = mount.get("Type", "N/A")
|
||||
source = mount.get("Source", "N/A")
|
||||
dest = mount.get("Destination", "N/A")
|
||||
table.add_row(service, name, m_type, source, dest)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not found_any:
|
||||
print("No volumes found for this project.")
|
||||
else:
|
||||
print("Failed to get container info.")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def manage_project(projects, action, target, extra_args=None):
|
||||
|
||||
def manage_project(projects, action, target, extra_args=None, container_filter=None):
|
||||
"""
|
||||
Execute the specified action (stop, update, restart, logs) on target project(s).
|
||||
|
||||
@@ -259,6 +401,7 @@ def manage_project(projects, action, target, extra_args=None):
|
||||
action (str): The action to perform ('stop', 'update', 'restart', 'logs').
|
||||
target (str): The target project name or 'all'.
|
||||
extra_args (list): Additional arguments for the command (e.g. for logs).
|
||||
container_filter (str): Optional container/service name to filter logs.
|
||||
"""
|
||||
|
||||
targets = []
|
||||
@@ -268,30 +411,60 @@ def manage_project(projects, action, target, extra_args=None):
|
||||
elif target in projects:
|
||||
targets = [(target, projects[target])]
|
||||
else:
|
||||
print(f"Error: Project '{target}' not found in {DOCKER_ROOT}")
|
||||
print("Available projects:", ", ".join(sorted(projects.keys())))
|
||||
console.print(f"[red]Error: Project '{target}' not found in {DOCKER_ROOT}[/red]")
|
||||
console.print(f"Available projects: {', '.join(sorted(projects.keys()))}")
|
||||
return
|
||||
|
||||
# Logs are special, usually run on one project interactively
|
||||
if action == "logs":
|
||||
if len(targets) > 1:
|
||||
print("Error: Logs can only be viewed for one project at a time.")
|
||||
console.print("[red]Error: Logs can only be viewed for one project at a time.[/red]")
|
||||
return
|
||||
name, path = targets[0]
|
||||
print(f"Viewing logs for {name}...")
|
||||
|
||||
# Filter by container/service if specified
|
||||
services_to_log = []
|
||||
if container_filter:
|
||||
cmd_ps = ["docker", "compose", "ps", "--format", "json"]
|
||||
res = run_command(cmd_ps, path, capture_output=True)
|
||||
if res and res.returncode == 0:
|
||||
try:
|
||||
lines = res.stdout.strip().split('\n')
|
||||
for line in lines:
|
||||
if not line: continue
|
||||
c = json.loads(line)
|
||||
s_name = c.get('Service', '')
|
||||
c_name = c.get('Name', '')
|
||||
# Check if filter matches service or container name
|
||||
if container_filter in s_name or container_filter in c_name:
|
||||
services_to_log.append(s_name)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not services_to_log:
|
||||
console.print(f"[yellow]No matching service/container found for '{container_filter}'. Showing all logs.[/yellow]")
|
||||
else:
|
||||
services_to_log = list(set(services_to_log))
|
||||
console.print(f"[blue]Showing logs for service(s): {', '.join(services_to_log)}[/blue]")
|
||||
|
||||
console.print(f"[blue]Viewing logs for {name}...[/blue]")
|
||||
cmd = ["docker", "compose", "logs"] + (extra_args or [])
|
||||
|
||||
if services_to_log:
|
||||
cmd.extend(services_to_log)
|
||||
|
||||
# For logs, we want to stream output directly to stdout, so no capture_output
|
||||
# and we might want to allow Ctrl+C to exit gracefully
|
||||
try:
|
||||
subprocess.run(cmd, cwd=path, check=False)
|
||||
except KeyboardInterrupt:
|
||||
print("\nLog view stopped.")
|
||||
console.print("\n[yellow]Log view stopped.[/yellow]")
|
||||
return
|
||||
|
||||
print(f"Performing '{action}' on {len(targets)} project(s)...")
|
||||
console.print(f"[bold]Performing '{action}' on {len(targets)} project(s)...[/bold]")
|
||||
|
||||
for name, path in targets:
|
||||
print(f"\n[{name}] -> {action}...")
|
||||
console.print(f"\n[bold cyan][{name}] -> {action}...[/bold cyan]")
|
||||
|
||||
# Execute the requested action
|
||||
if action == "stop":
|
||||
@@ -305,21 +478,21 @@ def manage_project(projects, action, target, extra_args=None):
|
||||
was_running = is_project_running(path)
|
||||
|
||||
if was_running:
|
||||
print(f" Stopping running containers for {name}...")
|
||||
console.print(f" [yellow]Stopping running containers for {name}...[/yellow]")
|
||||
run_command(["docker", "compose", "stop"], path)
|
||||
|
||||
print(f" Pulling latest images for {name}...")
|
||||
console.print(f" [blue]Pulling latest images for {name}...[/blue]")
|
||||
pull_res = run_command(["docker", "compose", "pull"], path)
|
||||
|
||||
if pull_res and pull_res.returncode == 0:
|
||||
if was_running:
|
||||
print(f" Restarting containers for {name}...")
|
||||
console.print(f" [green]Restarting containers for {name}...[/green]")
|
||||
run_command(["docker", "compose", "up", "-d"], path)
|
||||
else:
|
||||
print(
|
||||
f" Project {name} was not running. Images updated, but not started.")
|
||||
console.print(
|
||||
f" [green]Project {name} was not running. Images updated, but not started.[/green]")
|
||||
else:
|
||||
print(f" Failed to pull images for {name}. Skipping update.")
|
||||
console.print(f" [red]Failed to pull images for {name}. Skipping update.[/red]")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -366,6 +539,7 @@ def main():
|
||||
# Logs command configuration
|
||||
logs_parser = subparsers.add_parser("logs", help="View container logs")
|
||||
logs_parser.add_argument("project", help="Project name to view logs for")
|
||||
logs_parser.add_argument("container", nargs="?", help="Container/Service name to filter logs (optional)")
|
||||
logs_parser.add_argument(
|
||||
"-f", "--follow", action="store_true", help="Follow log output")
|
||||
logs_parser.add_argument(
|
||||
@@ -394,7 +568,7 @@ def main():
|
||||
if args.tail:
|
||||
extra_args.extend(["--tail", args.tail])
|
||||
|
||||
manage_project(projects, "logs", args.project, extra_args=extra_args)
|
||||
manage_project(projects, "logs", args.project, extra_args=extra_args, container_filter=args.container)
|
||||
|
||||
elif args.command in ["stop", "update", "restart"]:
|
||||
target = args.project
|
||||
|
||||
Reference in New Issue
Block a user