diff --git a/README.md b/README.md index de8be6a..7eda4ae 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,22 @@ Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: uvx paskia serve --rp-id example.com ``` -On the first run it downloads the software and prints a registration link for the Admin. Consider `uv tool install paskia` for a permanent install of `paskia` CLI. +On the first run it downloads the software and prints a registration link for the Admin. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. The server will start up on [localhost:4401](http://localhost:4401) "for authentication required", serving for `*.example.com`. -If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps. +Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps. + +A quick example without any config file: +```fish +sudo caddy reverse-proxy --from example.com --to :4401 +``` + +For a permanent install of `paskia` CLI command, not needing `uvx`: + +```fish +uv tool install paskia +``` ## Configuration @@ -44,12 +55,12 @@ There is no config file. Pass only the options on CLI: paskia serve [options] ``` -Options (all optional): +Optional options: - Listen address (one of): * `[host]:port`: Address and port (default: `localhost:4401`) * `unix:/path.sock`: Unix socket -- `--rp-id `: Domain name for authentication (required for production) +- `--rp-id `: Main domain (required for production) - `--rp-name ""`: Name of your company or site (default: same as rp-id) - `--origin `: Explicit single site (default: `https://`) - `--auth-host `: Dedicated authentication site (e.g., `auth.example.com`) diff --git a/examples/index.html b/examples/index.html index fa61440..0b94774 100644 --- a/examples/index.html +++ b/examples/index.html @@ -55,7 +55,7 @@

Browser Mode (full page)

-

Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nxinx):

+

Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):

diff --git a/paskia/fastapi/__main__.py b/paskia/fastapi/__main__.py index 4fd81a0..ae96943 100644 --- a/paskia/fastapi/__main__.py +++ b/paskia/fastapi/__main__.py @@ -111,7 +111,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None: "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" ) p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") - p.add_argument("--origin", help="Origin URL (default: https://)") + p.add_argument( + "--origin", + action="append", + dest="origins", + metavar="URL", + help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.", + ) p.add_argument( "--auth-host", help=( @@ -166,39 +172,62 @@ def main(): else: host = port = uds = all_ifaces = None # type: ignore - # Export configuration via environment for lifespan initialization in each process - os.environ.setdefault("PASKIA_RP_ID", args.rp_id) - if args.rp_name: - os.environ["PASKIA_RP_NAME"] = args.rp_name - if args.origin: - os.environ["PASKIA_ORIGIN"] = args.origin - if getattr(args, "auth_host", None): - os.environ["PASKIA_AUTH_HOST"] = args.auth_host - else: + # Collect origins and handle auth_host + origins = getattr(args, "origins", None) or [] + if not getattr(args, "auth_host", None): # Preserve pre-set env variable if CLI option omitted args.auth_host = os.environ.get("PASKIA_AUTH_HOST") if args.auth_host: + # Normalize auth_host with scheme + if "://" not in args.auth_host: + args.auth_host = f"https://{args.auth_host}" + validate_auth_host(args.auth_host, args.rp_id) from paskia.util import hostutil as _hostutil # local import _hostutil.reload_config() + # If origins are configured, ensure auth_host is included at top + if origins: + # Insert auth_host at the beginning (Passkey.__init__ will normalize/dedupe) + origins.insert(0, args.auth_host) + + # Export configuration via single JSON env variable for worker processes + # (PASKIA_DEVMODE is kept separate as it's externally defined) + # All keys are always present; None is used where no value is configured + import json + + config = { + "rp_id": args.rp_id, + "rp_name": args.rp_name or None, + "origins": origins or None, + "auth_host": args.auth_host or None, + "default_admin": os.getenv("PASKIA_DEFAULT_ADMIN") or None, + "default_org": os.getenv("PASKIA_DEFAULT_ORG") or None, + } + os.environ["PASKIA_CONFIG"] = json.dumps(config) + # One-time initialization + bootstrap before starting any server processes. # Lifespan in worker processes will call globals.init with bootstrap disabled. from paskia import globals as _globals # local import asyncio.run( _globals.init( - rp_id=args.rp_id, - rp_name=args.rp_name, - origin=args.origin, - default_admin=os.getenv("PASKIA_DEFAULT_ADMIN") or None, - default_org=os.getenv("PASKIA_DEFAULT_ORG") or None, + rp_id=config["rp_id"], + rp_name=config["rp_name"], + origins=config["origins"], + default_admin=config["default_admin"], + default_org=config["default_org"], bootstrap=True, ) ) + # Print startup configuration + from paskia.util import startupbox + + startupbox.print_startup_config(_globals.passkey.instance, args, host, port, uds) + # Handle recover-admin command (no server start) if args.command == "reset": from paskia.fastapi import reset as reset_cmd # local import diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 39d89ef..adc7491 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -15,26 +15,24 @@ from paskia.util import frontend, hostutil, passphrase async def lifespan(app: FastAPI): # pragma: no cover - startup path """Application lifespan to ensure globals (DB, passkey) are initialized in each process. - We populate configuration from environment variables (set by the CLI entrypoint) + Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that uvicorn reload / multiprocess workers inherit the settings. + All keys are guaranteed to exist; values are already normalized by __main__.py. """ + import json + from paskia import globals - rp_id = os.getenv("PASKIA_RP_ID", "localhost") - rp_name = os.getenv("PASKIA_RP_NAME") or None - origin = os.getenv("PASKIA_ORIGIN") or None - default_admin = ( - os.getenv("PASKIA_DEFAULT_ADMIN") or None - ) # still passed for context - default_org = os.getenv("PASKIA_DEFAULT_ORG") or None + config = json.loads(os.environ["PASKIA_CONFIG"]) + try: # CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work await globals.init( - rp_id=rp_id, - rp_name=rp_name, - origin=origin, - default_admin=default_admin, - default_org=default_org, + rp_id=config["rp_id"], + rp_name=config["rp_name"], + origins=config["origins"], + default_admin=config["default_admin"], + default_org=config["default_org"], bootstrap=False, ) except ValueError as e: diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 70b82a0..4efca50 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -42,19 +42,30 @@ def websocket_error_handler(func): app = FastAPI() +def _validate_origin(ws: WebSocket) -> str: + """Extract and validate origin from WebSocket request headers. + + Raises: + ValueError: If origin header is missing or not in allowed list + """ + origin = ws.headers.get("origin") + if not origin: + raise ValueError("Origin header is required for WebSocket connections") + return passkey.instance.validate_origin(origin) + + async def register_chat( ws: WebSocket, user_uuid: UUID, user_name: str, + origin: str, credential_ids: list[bytes] | None = None, - origin: str | None = None, ): """Generate registration options and send them to the client.""" options, challenge = passkey.instance.reg_generate_options( user_id=user_uuid, user_name=user_name, credential_ids=credential_ids, - origin=origin, ) await ws.send_json({"optionsJSON": options}) response = await ws.receive_json() @@ -75,7 +86,7 @@ async def websocket_register_add( - Normal session via auth cookie (requires recent authentication) - Reset token supplied as ?reset=... (auth cookie ignored) """ - origin = ws.headers["origin"] + origin = _validate_origin(ws) host = origin.split("://", 1)[1] if reset is not None: if not passphrase.is_well_formed(reset): @@ -100,7 +111,7 @@ async def websocket_register_add( challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) # WebAuthn registration - credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin) + credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids) # Create a new session and store everything in database token = create_token() @@ -131,7 +142,7 @@ async def websocket_register_add( @app.websocket("/authenticate") @websocket_error_handler async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): - origin = ws.headers["origin"] + origin = _validate_origin(ws) host = origin.split("://", 1)[1] # If there's an existing session, restrict to that user's credentials (reauth) @@ -166,7 +177,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): raise ValueError("This passkey belongs to a different account") # Verify the credential matches the stored data - passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin) + passkey.instance.auth_verify(credential, challenge, stored_cred, origin) # Update both credential and user's last_seen timestamp await db.instance.login(stored_cred.user_uuid, stored_cred) diff --git a/paskia/globals.py b/paskia/globals.py index fba9fb8..5d8508f 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -29,7 +29,7 @@ class Manager(Generic[T]): async def init( rp_id: str = "localhost", rp_name: str | None = None, - origin: str | None = None, + origins: list[str] | None = None, default_admin: str | None = None, default_org: str | None = None, *, @@ -45,7 +45,7 @@ async def init( passkey.instance = Passkey( rp_id=rp_id, rp_name=rp_name or rp_id, - origin=origin, + origins=origins, ) # Test if we have a database already initialized, otherwise use SQL diff --git a/paskia/sansio.py b/paskia/sansio.py index c7854ad..aa82d6c 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -47,7 +47,7 @@ class Passkey: self, rp_id: str, rp_name: str | None = None, - origin: str | None = None, + origins: list[str] | None = None, supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None, ): """ @@ -56,27 +56,30 @@ class Passkey: Args: rp_id: Your security domain (e.g. "example.com") rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. - origin: The origin URL of the application (e.g. "https://app.example.com"). - If no scheme is provided, "https://" will be prepended. - Must be a subdomain or same as rp_id, with port and scheme but no path included. + origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]). + Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed. supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). Raises: - ValueError: If the origin domain doesn't match or isn't a subdomain of rp_id. + ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id. """ self.rp_id = rp_id self.rp_name = rp_name or rp_id - self.origin = self._normalize_and_validate_origin(origin, rp_id) + self.allowed_origins: set[str] | None = None + if origins: + # Normalize and deduplicate origins into a set for O(1) lookups + self.allowed_origins = { + self._normalize_and_validate_origin(o, rp_id) for o in origins + } self.supported_pub_key_algs = supported_pub_key_algs or [ COSEAlgorithmIdentifier.EDDSA, COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, ] - def _normalize_and_validate_origin(self, origin: str | None, rp_id: str) -> str: - if origin is None: - origin = f"https://{rp_id}" - elif "://" not in origin: + def _normalize_and_validate_origin(self, origin: str, rp_id: str) -> str: + """Normalize and validate an origin URL against the rp_id.""" + if "://" not in origin: origin = f"https://{origin}" hostname = urlparse(origin).hostname @@ -90,6 +93,24 @@ class Passkey: f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'" ) + def validate_origin(self, origin: str) -> str: + """Validate that origin is allowed and return the normalized form. + + Args: + origin: The origin URL to validate (from WebSocket request header) + + Returns: + The validated origin URL + + Raises: + ValueError: If origin is not in the allowed list (when origins are configured) + or if origin is not a valid subdomain of rp_id + """ + normalized = self._normalize_and_validate_origin(origin, self.rp_id) + if self.allowed_origins is not None and normalized not in self.allowed_origins: + raise ValueError(f"Origin '{origin}' is not in the allowed origins list") + return normalized + ### Registration Methods ### def reg_generate_options( @@ -137,14 +158,16 @@ class Passkey: response_json: dict | str, expected_challenge: bytes, user_uuid: UUID, - origin: str | None = None, + origin: str, ) -> Credential: """ Verify registration response. Args: - credential: The credential response from the client + response_json: The credential response from the client expected_challenge: The expected challenge bytes + user_uuid: The user's UUID + origin: The origin URL (required, must be pre-validated) Returns: Registration verification result @@ -153,7 +176,7 @@ class Passkey: registration = verify_registration_response( credential=credential, expected_challenge=expected_challenge, - expected_origin=origin or self.origin, + expected_origin=origin, expected_rp_id=self.rp_id, ) return Credential( @@ -206,7 +229,7 @@ class Passkey: credential: AuthenticationCredential, expected_challenge: bytes, stored_cred: Credential, - origin: str | None = None, + origin: str, ) -> VerifiedAuthentication: """ Verify authentication response against locally stored credential data. @@ -215,13 +238,13 @@ class Passkey: credential: The authentication credential response from the client expected_challenge: The earlier generated challenge bytes stored_cred: The server stored credential record (modified by this function) + origin: The origin URL (required, must be pre-validated) """ - expected_origin = origin or self.origin # Verify the authentication response verification = verify_authentication_response( credential=credential, expected_challenge=expected_challenge, - expected_origin=expected_origin, + expected_origin=origin, expected_rp_id=self.rp_id, credential_public_key=stored_cred.public_key, credential_current_sign_count=stored_cred.sign_count, diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index c5be02f..12d4f4f 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -1,28 +1,38 @@ """Utilities for determining the auth UI host and base URLs.""" +import json import os from functools import lru_cache from urllib.parse import urlparse, urlsplit from paskia.globals import passkey as global_passkey -_AUTH_HOST_ENV = "PASKIA_AUTH_HOST" - def _default_origin_scheme() -> str: - origin_url = urlparse(global_passkey.instance.origin) - return origin_url.scheme or "https" + """Get the default scheme from configured origins, or fallback to https.""" + allowed = global_passkey.instance.allowed_origins + if allowed: + # Pick any origin from the set + origin_url = urlparse(next(iter(allowed))) + return origin_url.scheme or "https" + return "https" @lru_cache(maxsize=1) def _load_config() -> tuple[str | None, str] | None: - raw = os.getenv(_AUTH_HOST_ENV) + """Load auth_host from PASKIA_CONFIG JSON, falling back to PASKIA_AUTH_HOST.""" + # Try PASKIA_CONFIG first (set by CLI) + config_json = os.getenv("PASKIA_CONFIG") + if config_json: + config = json.loads(config_json) + raw = config["auth_host"] # Always present, may be None + else: + # Fallback for external usage (e.g., PASKIA_AUTH_HOST set directly) + raw = os.getenv("PASKIA_AUTH_HOST") + if not raw: return None - candidate = raw.strip() - if not candidate: - return None - parsed = urlparse(candidate if "://" in candidate else f"//{candidate}") + parsed = urlparse(raw if "://" in raw else f"//{raw}") netloc = parsed.netloc or parsed.path if not netloc: return None @@ -53,8 +63,14 @@ def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> st scheme_to_use = scheme or _default_origin_scheme() netloc = host.strip("/") else: - origin = global_passkey.instance.origin.rstrip("/") - return f"{origin}{ui_base_path()}" + # Use the first allowed origin, or fallback to rp_id + allowed = global_passkey.instance.allowed_origins + if allowed: + origin = allowed[0].rstrip("/") + return f"{origin}{ui_base_path()}" + # Fallback: construct from rp_id + rp_id = global_passkey.instance.rp_id + return f"https://{rp_id}{ui_base_path()}" base = f"{scheme_to_use}://{netloc}".rstrip("/") path = ui_base_path().lstrip("/") diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py new file mode 100644 index 0000000..bdb2190 --- /dev/null +++ b/paskia/util/startupbox.py @@ -0,0 +1,73 @@ +"""Startup configuration box formatting utilities.""" + +import os + +BOX_WIDTH = 60 # Inner width (excluding box chars) + + +def line(text: str = "") -> str: + """Format a line inside the box with proper padding, truncating if needed.""" + if len(text) > BOX_WIDTH: + text = text[: BOX_WIDTH - 1] + "…" + return f"┃ {text:<{BOX_WIDTH}} ┃\n" + + +def top() -> str: + return "┏" + "━" * (BOX_WIDTH + 2) + "┓\n" + + +def bottom() -> str: + return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n" + + +def print_startup_config(passkey_instance, args, host, port, uds) -> None: + """Print server configuration on startup.""" + from sys import stderr + + from paskia._version import __version__ + + lines = [top()] + lines.append(line(" ▄▄▄▄▄")) + lines.append(line("█ █ Paskia " + __version__)) + lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄")) + lines.append(line("█ █▀▀▀▀█▀▀█▀▀█")) + lines.append(line(" ▀▀▀▀▀")) + + # Format auth host section + auth_host = getattr(args, "auth_host", None) + if auth_host: + lines.append(line(f"Auth Host: {auth_host}")) + + # Show frontend URL if in dev mode + devmode = os.environ.get("PASKIA_DEVMODE") + if devmode: + lines.append(line(f"Dev Frontend: {devmode}")) + + # Format listen address with scheme + if uds: + listen = f"unix:{uds}" + elif host: + listen = f"http://{host}:{port}" + else: + listen = f"http://0.0.0.0:{port} + [::]:{port}" + lines.append(line(f"Backend: {listen}")) + + # Relying Party line (omit name if same as id) + rp_id = passkey_instance.rp_id + rp_name = passkey_instance.rp_name + if rp_name and rp_name != rp_id: + lines.append(line(f"Relying Party: {rp_id} ({rp_name})")) + else: + lines.append(line(f"Relying Party: {rp_id}")) + + # Format origins section + allowed = passkey_instance.allowed_origins + if allowed: + lines.append(line("Permitted Origins:")) + for origin in sorted(allowed): + lines.append(line(f" - {origin}")) + else: + lines.append(line(f"Origin: {rp_id} and all subdomains allowed")) + + lines.append(bottom()) + stderr.write("".join(lines)) diff --git a/scripts/dev.py b/scripts/dev.py old mode 100644 new mode 100755 index 6599134..24a3f8b --- a/scripts/dev.py +++ b/scripts/dev.py @@ -1,16 +1,19 @@ -#!/usr/bin/env python3 -"""Development server script for Paskia. +#!/usr/bin/env -S uv run +"""Run Vite development server for frontend and FastAPI backend with auto-reload. This script is only available when running from the git repository source, not from the installed package. It starts both the Vite frontend dev server and the FastAPI backend with auto-reload enabled. Usage: - python scripts/dev.py [options...] + uv run scripts/dev.py [host:port] [options...] -All options are forwarded to `paskia serve`. +The optional host:port argument sets where the Vite frontend listens. +All other options are forwarded to `paskia serve`. +Backend always listens on localhost:4402. """ +import argparse import atexit import os import shutil @@ -21,14 +24,10 @@ from pathlib import Path from sys import stderr from threading import Thread -# Set dev mode environment variable BEFORE importing anything from paskia -os.environ["PASKIA_DEVMODE"] = "1" +from paskia.fastapi.__main__ import parse_endpoint -# Ensure the package is importable when running from repo root -sys.path.insert(0, str(Path(__file__).parent.parent)) - -DEFAULT_DEV_PORT = 4402 -DEV_SERVER = "http://localhost:4403" +DEFAULT_VITE_PORT = 4403 # overrides by CLI option +BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts NO_FRONTEND_TOOL = """\ ┃ ⚠️ deno, npm or bunx needed to run the frontend server. @@ -48,17 +47,19 @@ BUN_BUG = """\ NO_FRONTEND = """\ ┃ -┃ Note: only static build of the frontend is served at localhost:4402. -┃ The page will not update with frontend code changes. +┃ The backend will still try reaching Vite at {vite_url} +┃ for various frontend assets, so make sure to start it manually. """ -def run_vite(): +def run_vite(vite_url: str, vite_host: str | None, vite_port: int): """Spawn the frontend dev server (deno, npm, or bunx) as a background process.""" devpath = Path(__file__).parent.parent / "frontend" if not (devpath / "package.json").exists(): - stderr.write(f"┃ ⚠️ Frontend source not found at {devpath}\n") - stderr.write(NO_FRONTEND) + stderr.write( + f"┃ ⚠️ Frontend source not found at {devpath}\n" + + NO_FRONTEND.format(vite_url=vite_url) + ) return options = [ @@ -74,24 +75,31 @@ def run_vite(): tool_name = option[0] break + # Add Vite CLI args for host/port + vite_args = ["--port", str(vite_port)] + if vite_host: + vite_args.extend(["--host", vite_host]) + vite_process = None def start_vite(): nonlocal vite_process if cmd is None: - stderr.write(NO_FRONTEND_TOOL) - stderr.write(NO_FRONTEND) + stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url)) return assert tool_name is not None try: if tool_name == "bunx": stderr.write(BUN_BUG) - stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n") - vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False) + full_cmd = cmd + vite_args + stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n") + vite_process = subprocess.Popen(full_cmd, cwd=str(devpath), shell=False) except Exception as e: - stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n") - stderr.write(NO_FRONTEND) + stderr.write( + f"┃ ⚠️ Vite couldn't start: {e}\n" + + NO_FRONTEND.format(vite_url=vite_url) + ) def cleanup(): if vite_process: @@ -108,20 +116,39 @@ def run_vite(): def main(): - # Start Vite dev server first - run_vite() + # Parse optional hostport argument for Vite frontend + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("hostport", nargs="?", default=None) + args, remaining = parser.parse_known_args() - # Set default origin for Vite if not specified - if "--origin" not in sys.argv: - os.environ.setdefault("PASKIA_ORIGIN", DEV_SERVER) + # Parse Vite endpoint + vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( + args.hostport, DEFAULT_VITE_PORT + ) - # Build argv for the main CLI - # Dev mode always listens on localhost:4402 (security: prevents public exposure) - # User args come after, allowing overrides of other options - sys.argv = ["paskia", "serve", f"localhost:{DEFAULT_DEV_PORT}"] + sys.argv[1:] + if vite_uds: + raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend") + # Handle all-interfaces case (:port syntax) + # Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev) + if all_ifaces: + vite_host = "0.0.0.0" + + # Build Vite URL for PASKIA_DEVMODE (always use localhost for URL) + vite_url = f"http://localhost:{vite_port}" + + # Start Vite dev server + run_vite(vite_url, vite_host, vite_port) + + # Set dev mode with Vite URL + os.environ["PASKIA_DEVMODE"] = vite_url + + # Import CLI after environment is set up from paskia.fastapi.__main__ import main as cli_main + # Build argv for the main CLI in Dev mode + # Backend always listens on localhost only (Vite proxies API requests) + sys.argv = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + remaining cli_main()