**feat(mirror,credentials): improve remote provisioning UX and token handling**
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / codesniffer-shellcheck (push) Has been cancelled
Mark stable commit / codesniffer-ruff (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / codesniffer-shellcheck (push) Has been cancelled
Mark stable commit / codesniffer-ruff (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
* Split mirror logic into atomic modules (remote check, provisioning, URL utils) * Normalize Git remote URLs and provider host detection * Add provider-specific token help URLs (GitHub, Gitea/Forgejo, GitLab) * Improve keyring handling with clear warnings and install hints * Gracefully fall back to prompt when keyring is unavailable * Fix provider hint override logic during remote provisioning
This commit is contained in:
21
src/pkgmgr/actions/mirror/remote_check.py
Normal file
21
src/pkgmgr/actions/mirror/remote_check.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# src/pkgmgr/actions/mirror/remote_check.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from pkgmgr.core.git import GitError, run_git
|
||||||
|
|
||||||
|
|
||||||
|
def probe_mirror(url: str, repo_dir: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Probe a remote mirror URL using `git ls-remote`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(True, "") on success,
|
||||||
|
(False, error_message) on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
run_git(["ls-remote", url], cwd=repo_dir)
|
||||||
|
return True, ""
|
||||||
|
except GitError as exc:
|
||||||
|
return False, str(exc)
|
||||||
77
src/pkgmgr/actions/mirror/remote_provision.py
Normal file
77
src/pkgmgr/actions/mirror/remote_provision.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# src/pkgmgr/actions/mirror/remote_provision.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
||||||
|
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
||||||
|
|
||||||
|
from .context import build_context
|
||||||
|
from .git_remote import determine_primary_remote_url
|
||||||
|
from .types import Repository
|
||||||
|
from .url_utils import hostport_from_git_url, normalize_provider_host
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_remote_repository(
|
||||||
|
repo: Repository,
|
||||||
|
repositories_base_dir: str,
|
||||||
|
all_repos: List[Repository],
|
||||||
|
preview: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Ensure that the remote repository exists using provider APIs.
|
||||||
|
|
||||||
|
This is ONLY called when ensure_remote=True.
|
||||||
|
"""
|
||||||
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
|
resolved_mirrors = ctx.resolved_mirrors
|
||||||
|
|
||||||
|
primary_url = determine_primary_remote_url(repo, resolved_mirrors)
|
||||||
|
if not primary_url:
|
||||||
|
print("[INFO] No remote URL could be derived; skipping remote provisioning.")
|
||||||
|
return
|
||||||
|
|
||||||
|
host_raw, _port = hostport_from_git_url(primary_url)
|
||||||
|
host = normalize_provider_host(host_raw)
|
||||||
|
|
||||||
|
owner = repo.get("account")
|
||||||
|
name = repo.get("repository")
|
||||||
|
|
||||||
|
if not host or not owner or not name:
|
||||||
|
print("[WARN] Missing host/account/repository; cannot ensure remote repo.")
|
||||||
|
print(f" host={host!r}, account={owner!r}, repository={name!r}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("------------------------------------------------------------")
|
||||||
|
print(f"[REMOTE ENSURE] {ctx.identifier}")
|
||||||
|
print(f"[REMOTE ENSURE] host: {host}")
|
||||||
|
print("------------------------------------------------------------")
|
||||||
|
|
||||||
|
spec = RepoSpec(
|
||||||
|
host=str(host),
|
||||||
|
owner=str(owner),
|
||||||
|
name=str(name),
|
||||||
|
private=bool(repo.get("private", True)),
|
||||||
|
description=str(repo.get("description", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_kind = str(repo.get("provider", "")).strip().lower() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ensure_remote_repo(
|
||||||
|
spec,
|
||||||
|
provider_hint=ProviderHint(kind=provider_kind),
|
||||||
|
options=EnsureOptions(
|
||||||
|
preview=preview,
|
||||||
|
interactive=True,
|
||||||
|
allow_prompt=True,
|
||||||
|
save_prompt_token_to_keyring=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(f"[REMOTE ENSURE] {result.status.upper()}: {result.message}")
|
||||||
|
if result.url:
|
||||||
|
print(f"[REMOTE ENSURE] URL: {result.url}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[ERROR] Remote provisioning failed: {exc}")
|
||||||
|
|
||||||
|
print()
|
||||||
@@ -1,131 +1,21 @@
|
|||||||
# src/pkgmgr/actions/mirror/setup_cmd.py
|
# src/pkgmgr/actions/mirror/setup_cmd.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Tuple
|
from typing import List
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from pkgmgr.core.git import GitError, run_git
|
|
||||||
from pkgmgr.core.remote_provisioning import ProviderHint, RepoSpec, ensure_remote_repo
|
|
||||||
from pkgmgr.core.remote_provisioning.ensure import EnsureOptions
|
|
||||||
|
|
||||||
from .context import build_context
|
from .context import build_context
|
||||||
from .git_remote import determine_primary_remote_url, ensure_origin_remote
|
from .git_remote import ensure_origin_remote
|
||||||
|
from .remote_check import probe_mirror
|
||||||
|
from .remote_provision import ensure_remote_repository
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
|
|
||||||
def _probe_mirror(url: str, repo_dir: str) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Probe a remote mirror URL using `git ls-remote`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(True, "") on success,
|
|
||||||
(False, error_message) on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
run_git(["ls-remote", url], cwd=repo_dir)
|
|
||||||
return True, ""
|
|
||||||
except GitError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _host_from_git_url(url: str) -> str:
|
|
||||||
url = (url or "").strip()
|
|
||||||
if not url:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if "://" in url:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
netloc = (parsed.netloc or "").strip()
|
|
||||||
if "@" in netloc:
|
|
||||||
netloc = netloc.split("@", 1)[1]
|
|
||||||
# keep optional :port
|
|
||||||
return netloc
|
|
||||||
|
|
||||||
# scp-like: git@host:owner/repo.git
|
|
||||||
if "@" in url and ":" in url:
|
|
||||||
after_at = url.split("@", 1)[1]
|
|
||||||
host = after_at.split(":", 1)[0]
|
|
||||||
return host.strip()
|
|
||||||
|
|
||||||
return url.split("/", 1)[0].strip()
|
|
||||||
|
|
||||||
def _ensure_remote_repository(
|
|
||||||
repo: Repository,
|
|
||||||
repositories_base_dir: str,
|
|
||||||
all_repos: List[Repository],
|
|
||||||
preview: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Ensure that the remote repository exists using provider APIs.
|
|
||||||
|
|
||||||
This is ONLY called when ensure_remote=True.
|
|
||||||
"""
|
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
|
||||||
resolved_mirrors = ctx.resolved_mirrors
|
|
||||||
|
|
||||||
primary_url = determine_primary_remote_url(repo, resolved_mirrors)
|
|
||||||
if not primary_url:
|
|
||||||
print("[INFO] No remote URL could be derived; skipping remote provisioning.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# IMPORTANT:
|
|
||||||
# - repo["provider"] is typically a provider *kind* (e.g. "github" / "gitea"),
|
|
||||||
# NOT a hostname. We derive the actual host from the remote URL.
|
|
||||||
host = _host_from_git_url(primary_url)
|
|
||||||
owner = repo.get("account")
|
|
||||||
name = repo.get("repository")
|
|
||||||
|
|
||||||
if not host or not owner or not name:
|
|
||||||
print("[WARN] Missing host/account/repository; cannot ensure remote repo.")
|
|
||||||
print(f" host={host!r}, account={owner!r}, repository={name!r}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("------------------------------------------------------------")
|
|
||||||
print(f"[REMOTE ENSURE] {ctx.identifier}")
|
|
||||||
print(f"[REMOTE ENSURE] host: {host}")
|
|
||||||
print("------------------------------------------------------------")
|
|
||||||
|
|
||||||
spec = RepoSpec(
|
|
||||||
host=str(host),
|
|
||||||
owner=str(owner),
|
|
||||||
name=str(name),
|
|
||||||
private=bool(repo.get("private", True)),
|
|
||||||
description=str(repo.get("description", "")),
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_kind = str(repo.get("provider", "")).strip().lower() or None
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = ensure_remote_repo(
|
|
||||||
spec,
|
|
||||||
provider_hint=ProviderHint(kind=provider_kind),
|
|
||||||
options=EnsureOptions(
|
|
||||||
preview=preview,
|
|
||||||
interactive=True,
|
|
||||||
allow_prompt=True,
|
|
||||||
save_prompt_token_to_keyring=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
print(f"[REMOTE ENSURE] {result.status.upper()}: {result.message}")
|
|
||||||
if result.url:
|
|
||||||
print(f"[REMOTE ENSURE] URL: {result.url}")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
# Keep action layer resilient
|
|
||||||
print(f"[ERROR] Remote provisioning failed: {exc}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_local_mirrors_for_repo(
|
def _setup_local_mirrors_for_repo(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
all_repos: List[Repository],
|
all_repos: List[Repository],
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Local setup:
|
|
||||||
- Ensure 'origin' remote exists and is sane
|
|
||||||
"""
|
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
|
|
||||||
print("------------------------------------------------------------")
|
print("------------------------------------------------------------")
|
||||||
@@ -144,17 +34,6 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
preview: bool,
|
preview: bool,
|
||||||
ensure_remote: bool,
|
ensure_remote: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Remote-side setup / validation.
|
|
||||||
|
|
||||||
Default behavior:
|
|
||||||
- Non-destructive checks using `git ls-remote`.
|
|
||||||
|
|
||||||
Optional behavior:
|
|
||||||
- If ensure_remote=True:
|
|
||||||
* Attempt to create missing repositories via provider API
|
|
||||||
* Uses TokenResolver (ENV -> keyring -> prompt)
|
|
||||||
"""
|
|
||||||
ctx = build_context(repo, repositories_base_dir, all_repos)
|
ctx = build_context(repo, repositories_base_dir, all_repos)
|
||||||
resolved_mirrors = ctx.resolved_mirrors
|
resolved_mirrors = ctx.resolved_mirrors
|
||||||
|
|
||||||
@@ -164,7 +43,7 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
print("------------------------------------------------------------")
|
print("------------------------------------------------------------")
|
||||||
|
|
||||||
if ensure_remote:
|
if ensure_remote:
|
||||||
_ensure_remote_repository(
|
ensure_remote_repository(
|
||||||
repo,
|
repo,
|
||||||
repositories_base_dir=repositories_base_dir,
|
repositories_base_dir=repositories_base_dir,
|
||||||
all_repos=all_repos,
|
all_repos=all_repos,
|
||||||
@@ -178,7 +57,7 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
ok, error_message = _probe_mirror(primary_url, ctx.repo_dir)
|
ok, error_message = probe_mirror(primary_url, ctx.repo_dir)
|
||||||
if ok:
|
if ok:
|
||||||
print(f"[OK] primary: {primary_url}")
|
print(f"[OK] primary: {primary_url}")
|
||||||
else:
|
else:
|
||||||
@@ -190,7 +69,7 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
return
|
return
|
||||||
|
|
||||||
for name, url in sorted(resolved_mirrors.items()):
|
for name, url in sorted(resolved_mirrors.items()):
|
||||||
ok, error_message = _probe_mirror(url, ctx.repo_dir)
|
ok, error_message = probe_mirror(url, ctx.repo_dir)
|
||||||
if ok:
|
if ok:
|
||||||
print(f"[OK] {name}: {url}")
|
print(f"[OK] {name}: {url}")
|
||||||
else:
|
else:
|
||||||
@@ -210,19 +89,6 @@ def setup_mirrors(
|
|||||||
remote: bool = True,
|
remote: bool = True,
|
||||||
ensure_remote: bool = False,
|
ensure_remote: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Setup mirrors for the selected repositories.
|
|
||||||
|
|
||||||
local:
|
|
||||||
- Configure local Git remotes (ensure 'origin' exists).
|
|
||||||
|
|
||||||
remote:
|
|
||||||
- Non-destructive remote checks using `git ls-remote`.
|
|
||||||
|
|
||||||
ensure_remote:
|
|
||||||
- If True, attempt to create missing remote repositories via provider APIs.
|
|
||||||
- This is explicit and NEVER enabled implicitly.
|
|
||||||
"""
|
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
if local:
|
if local:
|
||||||
_setup_local_mirrors_for_repo(
|
_setup_local_mirrors_for_repo(
|
||||||
|
|||||||
67
src/pkgmgr/actions/mirror/url_utils.py
Normal file
67
src/pkgmgr/actions/mirror/url_utils.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# src/pkgmgr/actions/mirror/url_utils.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def hostport_from_git_url(url: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract (host, port) from common Git remote URL formats.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ssh://git@host:2201/owner/repo.git
|
||||||
|
- https://host/owner/repo.git
|
||||||
|
- git@host:owner/repo.git (scp-like; no explicit port)
|
||||||
|
"""
|
||||||
|
url = (url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
if "://" in url:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
netloc = (parsed.netloc or "").strip()
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = netloc.split("@", 1)[1]
|
||||||
|
|
||||||
|
# IPv6 bracket form: [::1]:2222
|
||||||
|
if netloc.startswith("[") and "]" in netloc:
|
||||||
|
host = netloc[1:netloc.index("]")]
|
||||||
|
rest = netloc[netloc.index("]") + 1 :]
|
||||||
|
port = rest[1:] if rest.startswith(":") else None
|
||||||
|
return host.strip(), (port.strip() if port else None)
|
||||||
|
|
||||||
|
if ":" in netloc:
|
||||||
|
host, port = netloc.rsplit(":", 1)
|
||||||
|
return host.strip(), (port.strip() or None)
|
||||||
|
|
||||||
|
return netloc.strip(), None
|
||||||
|
|
||||||
|
# scp-like: git@host:owner/repo.git
|
||||||
|
if "@" in url and ":" in url:
|
||||||
|
after_at = url.split("@", 1)[1]
|
||||||
|
host = after_at.split(":", 1)[0].strip()
|
||||||
|
return host, None
|
||||||
|
|
||||||
|
host = url.split("/", 1)[0].strip()
|
||||||
|
return host, None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_provider_host(host: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize host for provider matching:
|
||||||
|
- strip brackets
|
||||||
|
- strip optional :port
|
||||||
|
- lowercase
|
||||||
|
"""
|
||||||
|
host = (host or "").strip()
|
||||||
|
if not host:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if host.startswith("[") and "]" in host:
|
||||||
|
host = host[1:host.index("]")]
|
||||||
|
|
||||||
|
if ":" in host and host.count(":") == 1:
|
||||||
|
host = host.rsplit(":", 1)[0]
|
||||||
|
|
||||||
|
return host.strip().lower()
|
||||||
@@ -9,15 +9,33 @@ from ..types import KeyringUnavailableError, TokenRequest, TokenResult
|
|||||||
|
|
||||||
|
|
||||||
def _import_keyring():
|
def _import_keyring():
|
||||||
|
"""
|
||||||
|
Import python-keyring.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyringUnavailableError if:
|
||||||
|
- library is missing
|
||||||
|
- no backend is configured / usable
|
||||||
|
- import fails for any reason
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import keyring # type: ignore
|
import keyring # type: ignore
|
||||||
|
|
||||||
return keyring
|
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise KeyringUnavailableError(
|
raise KeyringUnavailableError(
|
||||||
"python-keyring is not available or no backend is configured."
|
"python-keyring is not installed."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
# Some environments have keyring installed but no usable backend.
|
||||||
|
# We do a lightweight "backend sanity check" by attempting to read the backend.
|
||||||
|
try:
|
||||||
|
_ = keyring.get_keyring()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise KeyringUnavailableError(
|
||||||
|
"python-keyring is installed but no usable keyring backend is configured."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return keyring
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class KeyringTokenProvider:
|
class KeyringTokenProvider:
|
||||||
|
|||||||
@@ -9,6 +9,37 @@ from typing import Optional
|
|||||||
from ..types import TokenRequest, TokenResult
|
from ..types import TokenRequest, TokenResult
|
||||||
|
|
||||||
|
|
||||||
|
def _token_help_url(provider_kind: str, host: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Return a provider-specific URL where a user can create/get an API token.
|
||||||
|
|
||||||
|
Keep this conservative and stable:
|
||||||
|
- GitHub: official token settings URL
|
||||||
|
- Gitea/Forgejo: common settings path on the given host
|
||||||
|
- GitLab: common personal access token path
|
||||||
|
"""
|
||||||
|
kind = (provider_kind or "").strip().lower()
|
||||||
|
h = (host or "").strip()
|
||||||
|
|
||||||
|
# GitHub (cloud)
|
||||||
|
if kind == "github":
|
||||||
|
return "https://github.com/settings/tokens"
|
||||||
|
|
||||||
|
# Gitea / Forgejo (self-hosted)
|
||||||
|
if kind in ("gitea", "forgejo"):
|
||||||
|
# Typical UI path: Settings -> Applications -> Access Tokens
|
||||||
|
# In many installations this is available at /user/settings/applications
|
||||||
|
base = f"https://{h}".rstrip("/")
|
||||||
|
return f"{base}/user/settings/applications"
|
||||||
|
|
||||||
|
# GitLab (cloud or self-hosted)
|
||||||
|
if kind == "gitlab":
|
||||||
|
base = "https://gitlab.com" if not h else f"https://{h}".rstrip("/")
|
||||||
|
return f"{base}/-/profile/personal_access_tokens"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PromptTokenProvider:
|
class PromptTokenProvider:
|
||||||
"""Interactively prompt for a token.
|
"""Interactively prompt for a token.
|
||||||
@@ -25,6 +56,11 @@ class PromptTokenProvider:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
owner_info = f" (owner: {request.owner})" if request.owner else ""
|
owner_info = f" (owner: {request.owner})" if request.owner else ""
|
||||||
|
help_url = _token_help_url(request.provider_kind, request.host)
|
||||||
|
|
||||||
|
if help_url:
|
||||||
|
print(f"[INFO] Create/get your token here: {help_url}")
|
||||||
|
|
||||||
prompt = f"Enter API token for {request.provider_kind} on {request.host}{owner_info}: "
|
prompt = f"Enter API token for {request.provider_kind} on {request.host}{owner_info}: "
|
||||||
token = (getpass(prompt) or "").strip()
|
token = (getpass(prompt) or "").strip()
|
||||||
if not token:
|
if not token:
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# src/pkgmgr/core/credentials/resolver.py
|
# src/pkgmgr/core/credentials/resolver.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .providers.env import EnvTokenProvider
|
from .providers.env import EnvTokenProvider
|
||||||
from .providers.keyring import KeyringTokenProvider
|
from .providers.keyring import KeyringTokenProvider
|
||||||
from .providers.prompt import PromptTokenProvider
|
from .providers.prompt import PromptTokenProvider
|
||||||
from .types import NoCredentialsError, TokenRequest, TokenResult
|
from .types import KeyringUnavailableError, NoCredentialsError, TokenRequest, TokenResult
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -26,6 +27,26 @@ class TokenResolver:
|
|||||||
self._env = EnvTokenProvider()
|
self._env = EnvTokenProvider()
|
||||||
self._keyring = KeyringTokenProvider()
|
self._keyring = KeyringTokenProvider()
|
||||||
self._prompt = PromptTokenProvider()
|
self._prompt = PromptTokenProvider()
|
||||||
|
self._warned_keyring: bool = False
|
||||||
|
|
||||||
|
def _warn_keyring_unavailable(self, exc: Exception) -> None:
|
||||||
|
if self._warned_keyring:
|
||||||
|
return
|
||||||
|
self._warned_keyring = True
|
||||||
|
|
||||||
|
msg = str(exc).strip() or "Keyring is unavailable."
|
||||||
|
print("[WARN] Keyring support is not available.", file=sys.stderr)
|
||||||
|
print(f" {msg}", file=sys.stderr)
|
||||||
|
print(" Tokens will NOT be persisted securely.", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(" To enable secure token storage, install python-keyring:", file=sys.stderr)
|
||||||
|
print(" pip install keyring", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(" Or install via system packages:", file=sys.stderr)
|
||||||
|
print(" sudo apt install python3-keyring", file=sys.stderr)
|
||||||
|
print(" sudo pacman -S python-keyring", file=sys.stderr)
|
||||||
|
print(" sudo dnf install python3-keyring", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
def get_token(
|
def get_token(
|
||||||
self,
|
self,
|
||||||
@@ -47,9 +68,11 @@ class TokenResolver:
|
|||||||
kr_res = self._keyring.get(request)
|
kr_res = self._keyring.get(request)
|
||||||
if kr_res:
|
if kr_res:
|
||||||
return kr_res
|
return kr_res
|
||||||
|
except KeyringUnavailableError as exc:
|
||||||
|
# Show a helpful warning once, then continue (prompt fallback).
|
||||||
|
self._warn_keyring_unavailable(exc)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Keyring missing/unavailable: ignore to allow prompt (workstations)
|
# Unknown keyring errors: do not block prompting; still avoid hard crash.
|
||||||
# or to fail cleanly below (headless CI without prompt).
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3) Prompt (optional)
|
# 3) Prompt (optional)
|
||||||
@@ -59,6 +82,8 @@ class TokenResolver:
|
|||||||
if opts.save_prompt_token_to_keyring:
|
if opts.save_prompt_token_to_keyring:
|
||||||
try:
|
try:
|
||||||
self._keyring.set(request, prompt_res.token)
|
self._keyring.set(request, prompt_res.token)
|
||||||
|
except KeyringUnavailableError as exc:
|
||||||
|
self._warn_keyring_unavailable(exc)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If keyring cannot store, still use token for this run.
|
# If keyring cannot store, still use token for this run.
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ def ensure_remote_repo(
|
|||||||
provider = reg.resolve(spec.host)
|
provider = reg.resolve(spec.host)
|
||||||
if provider_hint and provider_hint.kind:
|
if provider_hint and provider_hint.kind:
|
||||||
forced = provider_hint.kind.strip().lower()
|
forced = provider_hint.kind.strip().lower()
|
||||||
provider = next(
|
forced_provider = next(
|
||||||
(p for p in reg.providers if getattr(p, "kind", "").lower() == forced),
|
(p for p in reg.providers if getattr(p, "kind", "").lower() == forced),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
if forced_provider is not None:
|
||||||
|
provider = forced_provider
|
||||||
|
|
||||||
if provider is None:
|
if provider is None:
|
||||||
raise UnsupportedProviderError(f"No provider matched host: {spec.host}")
|
raise UnsupportedProviderError(f"No provider matched host: {spec.host}")
|
||||||
|
|||||||
Reference in New Issue
Block a user