231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
from .common import (
|
|
ANSI_ESCAPE_PATTERN,
|
|
DEFAULT_ENV_NAME,
|
|
ENV_NAME_PATTERN,
|
|
Any,
|
|
Callable,
|
|
InstallerError,
|
|
Path,
|
|
Runner,
|
|
Which,
|
|
_project_root,
|
|
json,
|
|
os,
|
|
re,
|
|
shutil,
|
|
subprocess,
|
|
sys,
|
|
urlsplit,
|
|
urlunsplit,
|
|
)
|
|
from .project import set_config_env
|
|
|
|
|
|
def normalize_api_base_url(value: str) -> str:
|
|
raw = value.strip()
|
|
if not raw:
|
|
raise InstallerError("NocoBase API base URL is required")
|
|
parsed = urlsplit(raw)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
raise InstallerError("NocoBase URL must be an absolute http:// or https:// URL")
|
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise InstallerError("NocoBase URL must not contain credentials, a query string, or a fragment")
|
|
hostname = parsed.hostname.lower()
|
|
is_local = hostname == "localhost" or hostname == "::1" or hostname.startswith("127.")
|
|
if parsed.scheme != "https" and not is_local:
|
|
raise InstallerError("A remote NocoBase API Key may only be sent to an https:// endpoint")
|
|
path = parsed.path.rstrip("/")
|
|
if not path.endswith("/api"):
|
|
path = f"{path}/api" if path else "/api"
|
|
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
|
|
|
|
def _parse_cli_json(output: str) -> dict[str, Any]:
|
|
decoder = json.JSONDecoder()
|
|
candidates: list[tuple[int, int, dict[str, Any]]] = []
|
|
for start, character in enumerate(output):
|
|
if character != "{":
|
|
continue
|
|
try:
|
|
value, end = decoder.raw_decode(output, start)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(value, dict):
|
|
candidates.append((start, end, value))
|
|
outer_candidates = [
|
|
candidate
|
|
for candidate in candidates
|
|
if not any(
|
|
other_start <= candidate[0]
|
|
and other_end >= candidate[1]
|
|
and (other_start, other_end) != (candidate[0], candidate[1])
|
|
for other_start, other_end, _value in candidates
|
|
)
|
|
]
|
|
if len(outer_candidates) != 1:
|
|
raise InstallerError("nb env info did not return one JSON object")
|
|
return outer_candidates[0][2]
|
|
|
|
|
|
def _nb_command_environment(nb_bin: str) -> dict[str, str]:
|
|
environment = os.environ.copy()
|
|
environment["NB_CLI_LOG_DISABLED"] = "1"
|
|
environment["NB_SKIP_STARTUP_UPDATE"] = "1"
|
|
nb_path = Path(nb_bin)
|
|
if nb_path.is_absolute():
|
|
nb_dir = str(nb_path.parent)
|
|
existing_path = environment.get("PATH", "")
|
|
parts = [part for part in existing_path.split(os.pathsep) if part]
|
|
if nb_dir not in parts:
|
|
environment["PATH"] = nb_dir + (os.pathsep + existing_path if existing_path else "")
|
|
return environment
|
|
|
|
|
|
def _read_nb_environment(
|
|
root: Path,
|
|
env_name: str,
|
|
*,
|
|
nb_bin: str,
|
|
runner: Runner,
|
|
) -> dict[str, Any] | None:
|
|
command = [nb_bin, "env", "info", env_name, "--json"]
|
|
try:
|
|
result = runner(
|
|
command,
|
|
cwd=str(root),
|
|
env=_nb_command_environment(nb_bin),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except OSError as error:
|
|
raise InstallerError(f"Could not execute nb env info: {error}") from error
|
|
if result.returncode == 0:
|
|
return _parse_cli_json(result.stdout)
|
|
diagnostic = ANSI_ESCAPE_PATTERN.sub("", f"{result.stdout}\n{result.stderr}")
|
|
if "is not configured" in diagnostic:
|
|
return None
|
|
raise InstallerError(
|
|
f"nb env info failed with exit code {result.returncode}; inspect the nb CLI diagnostic locally"
|
|
)
|
|
|
|
|
|
def _api_details(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
api = payload.get("api")
|
|
auth = api.get("auth") if isinstance(api, dict) else None
|
|
api_base_url = api.get("apiBaseUrl") if isinstance(api, dict) else None
|
|
auth_type = auth.get("type") if isinstance(auth, dict) else None
|
|
normalized_url = api_base_url.strip().rstrip("/") if isinstance(api_base_url, str) else None
|
|
normalized_type = auth_type.strip() if isinstance(auth_type, str) else None
|
|
return normalized_url, normalized_type
|
|
|
|
|
|
def _suggest_env_name(api_base_url: str) -> str:
|
|
hostname = urlsplit(api_base_url).hostname or "server"
|
|
suffix = re.sub(r"[^a-z0-9]+", "-", hostname.lower()).strip("-")[:32] or "server"
|
|
return f"{DEFAULT_ENV_NAME}-{suffix}"
|
|
|
|
|
|
def connect_with_personal_key(
|
|
project_root: str | Path | None,
|
|
api_base_url: str,
|
|
*,
|
|
env_name: str = DEFAULT_ENV_NAME,
|
|
runner: Runner = subprocess.run,
|
|
which: Which = shutil.which,
|
|
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
|
|
) -> dict[str, Any]:
|
|
"""Bind a personal API Key through nb's private TTY prompt.
|
|
|
|
The key is never accepted by this function, placed in argv, inherited from
|
|
the process environment, captured, printed, or returned. The child nb CLI
|
|
reads it directly from the user-controlled terminal.
|
|
"""
|
|
|
|
if not ENV_NAME_PATTERN.fullmatch(env_name):
|
|
raise InstallerError(
|
|
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
|
|
)
|
|
if not stdin_isatty():
|
|
raise InstallerError(
|
|
"Secure API Key entry requires a user-controlled local terminal. "
|
|
"Do not paste the Key into Agent chat or pass it with --access-token."
|
|
)
|
|
root = _project_root(project_root)
|
|
normalized_url = normalize_api_base_url(api_base_url)
|
|
discovered_nb = which("nb")
|
|
if not discovered_nb:
|
|
raise InstallerError("nb was not found on PATH")
|
|
nb_bin = os.path.abspath(discovered_nb)
|
|
existing = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
|
|
|
|
if existing is None:
|
|
interactive_command = [
|
|
nb_bin,
|
|
"env",
|
|
"add",
|
|
env_name,
|
|
"--api-base-url",
|
|
normalized_url,
|
|
"--auth-type",
|
|
"token",
|
|
]
|
|
action = "created"
|
|
else:
|
|
existing_url, _auth_type = _api_details(existing)
|
|
if existing_url != normalized_url.rstrip("/"):
|
|
suggestion = _suggest_env_name(normalized_url)
|
|
raise InstallerError(
|
|
f"Environment {env_name!r} already targets {existing_url or 'an unconfigured endpoint'!r}; "
|
|
f"use a new name such as {suggestion!r} instead of overwriting it"
|
|
)
|
|
interactive_command = [nb_bin, "env", "auth", env_name, "--auth-type", "token"]
|
|
action = "reauthenticated"
|
|
|
|
try:
|
|
result = runner(
|
|
interactive_command,
|
|
cwd=str(root),
|
|
env=_nb_command_environment(nb_bin),
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except OSError as error:
|
|
raise InstallerError(f"Could not execute the interactive nb Key prompt: {error}") from error
|
|
if result.returncode != 0:
|
|
raise InstallerError(
|
|
f"Interactive API Key authentication failed with exit code {result.returncode}; "
|
|
"revoke the exposed or invalid Key if necessary and retry through the installer"
|
|
)
|
|
|
|
verified = _read_nb_environment(root, env_name, nb_bin=nb_bin, runner=runner)
|
|
if verified is None:
|
|
raise InstallerError("nb reported success but the saved environment could not be read back")
|
|
verified_url, auth_type = _api_details(verified)
|
|
if verified_url != normalized_url.rstrip("/") or auth_type != "token":
|
|
raise InstallerError("The saved nb environment did not verify as token authentication for the requested URL")
|
|
|
|
list_result = runner(
|
|
[nb_bin, "env", "list"],
|
|
cwd=str(root),
|
|
env=_nb_command_environment(nb_bin),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if list_result.returncode != 0:
|
|
raise InstallerError("API Key was saved, but nb env list readback failed")
|
|
config_result = set_config_env(root, env_name)
|
|
return {
|
|
"ok": True,
|
|
"action": action,
|
|
"env": env_name,
|
|
"apiBaseUrl": normalized_url,
|
|
"authType": "token",
|
|
"keyExposure": "private-tty-only",
|
|
"configChanged": config_result["changed"],
|
|
"configBackupPath": config_result["backupPath"],
|
|
}
|