Skip to content

Commit a6c03e0

Browse files
committed
Add web admin configuration page
- Full config management via /admin/config - Edit server settings (port, working dir, browse dir, server name) - Edit security settings (device lock, local only, rate limits) - Edit TLS settings and token rotation - Permission mode selection (default, acceptEdits, bypassPermissions) - Token rotation from web UI - Save browse_dir, permission_mode, rate_limit settings to .env
1 parent d424229 commit a6c03e0

10 files changed

Lines changed: 2289 additions & 350 deletions

File tree

build/lib/darkcode_server/cli.py

Lines changed: 100 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,48 @@ def menu_start_server():
354354
console.print(f"\n[red]Error: {e}[/]")
355355

356356

357-
async def _run_server(server: DarkCodeServer):
358-
"""Run the server until interrupted."""
357+
async def _run_server(server: DarkCodeServer, show_status: bool = True):
358+
"""Run the server until interrupted with live status display."""
359+
import time
360+
from datetime import timedelta
361+
359362
await server.start()
363+
start_time = time.time()
364+
360365
try:
361-
await asyncio.Future() # Run forever
366+
if show_status:
367+
# Create a live status display that updates periodically
368+
status_table = Table(show_header=False, box=None, padding=(0, 1))
369+
status_table.add_column("", style="green", width=2)
370+
status_table.add_column("", style="dim")
371+
372+
with Live(console=console, refresh_per_second=1, transient=False) as live:
373+
while True:
374+
# Calculate uptime
375+
uptime_secs = int(time.time() - start_time)
376+
uptime = str(timedelta(seconds=uptime_secs))
377+
378+
# Get session count from server
379+
session_count = len(server.sessions) if hasattr(server, 'sessions') else 0
380+
state = server.state.value if hasattr(server, 'state') else "running"
381+
382+
# Build status display
383+
status_text = Text()
384+
status_text.append("[*] ", style="bold green")
385+
status_text.append("DARKCODE SERVER RUNNING", style="bold green")
386+
status_text.append(" | ", style="dim")
387+
status_text.append(f"{uptime}", style="cyan")
388+
status_text.append(" | ", style="dim")
389+
status_text.append(f"{session_count} session{'s' if session_count != 1 else ''}", style="yellow")
390+
status_text.append(" | ", style="dim")
391+
status_text.append(f"{state}", style="magenta")
392+
status_text.append(" | ", style="dim")
393+
status_text.append("Ctrl+C to stop", style="dim italic")
394+
395+
live.update(status_text)
396+
await asyncio.sleep(1)
397+
else:
398+
await asyncio.Future() # Run forever without status
362399
finally:
363400
await server.stop()
364401

@@ -656,86 +693,70 @@ def main(ctx, version, classic):
656693
return
657694

658695
if ctx.invoked_subcommand is None:
659-
show_banner()
660-
661696
if classic:
662697
# Use old menu if explicitly requested
698+
show_banner()
663699
interactive_menu()
664700
else:
665-
# Default to modern prompt_toolkit dialogs with dropdowns
701+
# Default to arrow-key navigation menu using prompt_toolkit
666702
try:
667703
from darkcode_server.prompt_ui import run_interactive_menu
668704
result = run_interactive_menu()
669705
if result:
670-
action, mode = result
706+
action, data = result
671707
if action == "start":
672-
# Start server with selected mode
708+
# data is a dict with mode, port, working_dir, no_web, save
673709
config = ServerConfig.load()
674-
if mode == "ssh":
675-
config.local_only = True
676-
else:
677-
config.local_only = False
678-
# Call the start command logic
679-
ctx.invoke(start, local_only=config.local_only, no_banner=True)
680-
elif action == "status":
681-
menu_status()
682-
elif action == "qr":
683-
menu_qr_code()
684-
elif action == "guest":
685-
menu_guest_codes()
686-
elif action == "guest_create":
687-
from darkcode_server.prompt_ui import show_guest_create_dialog
688-
guest_data = show_guest_create_dialog()
689-
if guest_data:
690-
from darkcode_server.security import GuestAccessManager
691-
config = ServerConfig.load()
692-
guest_mgr = GuestAccessManager(config.config_dir / "guests.db")
693-
result = guest_mgr.create_guest_code(**guest_data)
694-
console.print(f"\n[green]Guest code created:[/] [bold]{result['code']}[/]")
695-
Prompt.ask("\n[dim]Press Enter to continue[/]")
696-
elif action == "guest_list":
697-
from click.testing import CliRunner
698-
runner = CliRunner()
699-
runner.invoke(guest_list, [], standalone_mode=False)
700-
Prompt.ask("\n[dim]Press Enter to continue[/]")
701-
elif action == "guest_revoke":
702-
code = Prompt.ask("[cyan]Code to revoke[/]")
703-
from click.testing import CliRunner
704-
runner = CliRunner()
705-
runner.invoke(guest_revoke, [code], standalone_mode=False)
706-
Prompt.ask("\n[dim]Press Enter to continue[/]")
707-
elif action == "guest_qr":
708-
code = Prompt.ask("[cyan]Code for QR[/]")
709-
from click.testing import CliRunner
710-
runner = CliRunner()
711-
runner.invoke(guest_qr, [code], standalone_mode=False)
712-
Prompt.ask("\n[dim]Press Enter to continue[/]")
713-
elif action == "config":
714-
menu_config()
715-
elif action == "security":
716-
menu_security()
710+
local_only = data.get("mode") == "ssh"
711+
port = data.get("port", config.port)
712+
working_dir = data.get("working_dir", str(config.working_dir))
713+
no_web = data.get("no_web", False)
714+
if data.get("save"):
715+
config.port = port
716+
config.working_dir = Path(working_dir)
717+
config.save()
718+
ctx.invoke(start, port=port, working_dir=working_dir, local_only=local_only, no_web=no_web, no_banner=True)
719+
elif action == "daemon_foreground":
720+
ctx.invoke(daemon, detach=False)
721+
elif action == "daemon_background":
722+
ctx.invoke(daemon, detach=True)
723+
elif action == "daemon_stop":
724+
ctx.invoke(stop)
717725
elif action == "setup":
718726
setup_wizard_menu()
719-
elif action == "install_tailscale":
720-
prompt_install_tailscale()
727+
elif action == "install":
728+
ctx.invoke(install)
729+
elif action == "uninstall":
730+
ctx.invoke(uninstall)
731+
elif action == "rotate_token":
732+
ctx.invoke(rotate_token)
733+
elif action == "client_cert":
734+
device_id = data
735+
ctx.invoke(client_cert, device_id=device_id)
721736
except ImportError as e:
722-
console.print(f"[yellow]Interactive dialogs require prompt_toolkit. Falling back to classic menu.[/]")
737+
console.print(f"[yellow]prompt_toolkit not installed. Falling back to classic menu.[/]")
723738
console.print(f"[dim]Install with: pip install prompt_toolkit[/]")
739+
show_banner()
724740
interactive_menu()
725741
except Exception as e:
726-
console.print(f"[yellow]Dialog error: {e}. Falling back to classic menu.[/]")
742+
import traceback
743+
console.print(f"[yellow]Menu error: {e}[/]")
744+
console.print(f"[dim]{traceback.format_exc()}[/]")
745+
show_banner()
727746
interactive_menu()
728747

729748

730749
@main.command()
731750
@click.option("--port", "-p", type=int, envvar="DARKCODE_PORT", help="Server port (default: 3100, env: DARKCODE_PORT)")
732751
@click.option("--token", "-t", envvar="DARKCODE_TOKEN", help="Auth token (env: DARKCODE_TOKEN)")
733-
@click.option("--working-dir", "-d", type=click.Path(exists=True), envvar="DARKCODE_WORKING_DIR", help="Working directory (env: DARKCODE_WORKING_DIR)")
752+
@click.option("--working-dir", "-d", type=click.Path(exists=True), envvar="DARKCODE_WORKING_DIR", help="Working directory for Claude (env: DARKCODE_WORKING_DIR)")
753+
@click.option("--browse-dir", "-b", type=click.Path(exists=True), envvar="DARKCODE_BROWSE_DIR", help="Default directory for app file browser (env: DARKCODE_BROWSE_DIR)")
734754
@click.option("--name", "-n", envvar="DARKCODE_SERVER_NAME", help="Server display name")
735755
@click.option("--local-only", "-l", is_flag=True, envvar="DARKCODE_LOCAL_ONLY", help="Only accept localhost connections (use with SSH tunnel)")
736756
@click.option("--no-banner", is_flag=True, help="Skip banner animation")
757+
@click.option("--no-web", is_flag=True, envvar="DARKCODE_NO_WEB", help="Disable web admin dashboard")
737758
@click.option("--save", "-s", is_flag=True, help="Save options to config file")
738-
def start(port, token, working_dir, name, local_only, no_banner, save):
759+
def start(port, token, working_dir, browse_dir, name, local_only, no_banner, no_web, save):
739760
"""Start the DarkCode server.
740761
741762
CONNECTION MODES:
@@ -782,10 +803,14 @@ def start(port, token, working_dir, name, local_only, no_banner, save):
782803
config.token = token
783804
if working_dir:
784805
config.working_dir = Path(working_dir)
806+
if browse_dir:
807+
config.browse_dir = Path(browse_dir)
785808
if name:
786809
config.server_name = name
787810
if local_only:
788811
config.local_only = True
812+
if no_web:
813+
config.web_admin_disabled = True
789814

790815
# Auto-save on first run or if requested
791816
if first_run or save:
@@ -843,6 +868,24 @@ def start(port, token, working_dir, name, local_only, no_banner, save):
843868
))
844869

845870
console.print(f"\n[green]Server listening on {config.bind_host}:{config.port}[/]")
871+
872+
# Show admin URL and PIN (unless disabled)
873+
if not config.web_admin_disabled:
874+
local_ips = config.get_local_ips()
875+
local_ip = local_ips[0]["address"] if local_ips else "127.0.0.1"
876+
protocol = "https" if config.tls_enabled else "http"
877+
console.print(f"[cyan]Web Admin:[/] {protocol}://{local_ip}:{config.port}/admin")
878+
879+
# Get and display web PIN
880+
try:
881+
from darkcode_server.web_admin import WebAdminHandler
882+
web_pin = WebAdminHandler.get_web_pin()
883+
console.print(f"[cyan]Web PIN:[/] [bold yellow]{web_pin}[/]")
884+
except ImportError:
885+
pass
886+
else:
887+
console.print("[dim]Web Admin: disabled (--no-web)[/]")
888+
846889
console.print("[dim]Press Ctrl+C to stop[/]\n")
847890

848891
server = DarkCodeServer(config)

build/lib/darkcode_server/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ class ServerConfig(BaseSettings):
3535
port: int = Field(default=3100, description="WebSocket server port")
3636
host: str = Field(default="0.0.0.0", description="Bind address (use 127.0.0.1 for local only)")
3737
token: str = Field(default_factory=get_default_token, description="Auth token")
38-
working_dir: Path = Field(default_factory=Path.cwd, description="Working directory")
38+
working_dir: Path = Field(default_factory=Path.cwd, description="Working directory for Claude")
39+
browse_dir: Optional[Path] = Field(default=None, description="Default directory for app file browser (defaults to working_dir)")
3940
server_name: str = Field(default_factory=get_hostname, description="Server display name")
4041

4142
# Security settings
@@ -65,6 +66,9 @@ class ServerConfig(BaseSettings):
6566
description="Claude permission mode: default, acceptEdits, or bypassPermissions"
6667
)
6768

69+
# Web admin settings
70+
web_admin_disabled: bool = Field(default=False, description="Disable web admin dashboard")
71+
6872
@property
6973
def bind_host(self) -> str:
7074
"""Get the actual bind address based on security settings."""
@@ -77,6 +81,16 @@ def is_exposed(self) -> bool:
7781
"""Check if server is exposed to network (not localhost-only)."""
7882
return self.host == "0.0.0.0" and not self.local_only
7983

84+
@property
85+
def effective_browse_dir(self) -> Path:
86+
"""Get the effective browse directory for the file browser.
87+
88+
Returns browse_dir if set, otherwise falls back to working_dir.
89+
"""
90+
if self.browse_dir is not None:
91+
return self.browse_dir.resolve()
92+
return self.working_dir.resolve()
93+
8094
@property
8195
def safe_working_dir(self) -> Path:
8296
"""Get validated working directory.

0 commit comments

Comments
 (0)