Render token scan as compact token table

This commit is contained in:
mpabi
2026-04-26 20:49:35 +02:00
parent a9d0d0117a
commit 2885d3a4fa
4 changed files with 399 additions and 226 deletions
+5 -7
View File
@@ -156,13 +156,11 @@ Podstawowe komendy tokenow:
./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03
```
`tokens scan` wypisuje waska tabele. Dla jednego endpointu zobaczysz
osobny wiersz `source=repo` oraz osobny wiersz `source=tokens.json`.
Ten sam numer `item` laczy oba wiersze w pare dla jednego endpointu.
Kolumna `token` pokazuje, gdzie jest token, a `sync` stawia `*` przy zrodle
prawdy. Kolumna `id` pokazuje `r1` dla tokena w repo albo `t1` dla tokena w
`tokens.json`, bez pokazywania sekretu. Endpoint jest rozbity na `server`,
`scheme`, `host` i `port`.
`tokens scan` wypisuje tabele `tokens`: jeden wiersz na logiczny token.
`token_ref` laczy nazwe tokena z markerem po prawej stronie: `*` oznacza, ze
remote i `tokens.json` sa zgodne oraz uprawnienia zostaly wczytane, `R` oznacza
token tylko w remote, a `S` token tylko w `tokens.json`. Kolumny `scope`, `org`
i `repo` sa maskami uprawnien; bez `*` maja wartosc `?????` albo `????`.
## Fetch i switch
+21 -26
View File
@@ -292,44 +292,40 @@ Przyklady:
## `tokens scan`
Skanuje remote URL-e w repo oraz lokalny `tokens.json`, laczy wpisy w pary po
endpoincie i pokazuje, gdzie przechowywany jest token.
endpoincie, userze i wartosci tokena, a potem pokazuje jeden logiczny wiersz
na token.
Przelaczniki:
- `--repo PATH`
Sciezka wewnatrz docelowego repo. Domyslnie biezacy katalog.
- `--verbose`
Pokazuje dodatkowo sekcje `context` i `scan`.
Typowy wynik:
```text
items
item source id server scheme host port credential repo_user store_user token sync
---- ----------- ---------- ------ ------ --------------- ----- ---------- --------- ---------- ----- ----
1 repo r1 gitea http 77.90.8.171 3001 auth u1 u1 yes
1 tokens.json t1 gitea http 77.90.8.171 3001 store u1 u1 yes *
tokens
item server host org repo user remote token_ref token scope org repo
---- ------ ------------- --------- ----------- ---- ------ --------- ------------ ------ ------ ------
1 gitea 77.90.8.171 edu-tools rv-launcher u1 r1 t1 * e59cc...13be +---+ +++++ ++++
```
Jesli remote istnieje, ale ma URL bez `LOGIN:TOKEN@`, wynik bedzie mial
w wierszu `repo` wartosci `credential=plain` i `token=no`. Jezeli ten sam
endpoint istnieje w `tokens.json`, drugi wiersz tego samego `item` pokaze
`source=tokens.json`, `id=t1`, `store_user=u1` i `token=yes`.
`token_ref` jest komorka stalej szerokosci: nazwa tokena jest po lewej, a marker
po prawej. Marker `*` oznacza, ze token w remote i `tokens.json` jest zgodny
oraz uprawnienia zostaly wczytane. Marker `R` oznacza token tylko w remote, a
`S` token tylko w `tokens.json`.
Ten sam numer `item` oznacza jedna pare logiczna dla jednego endpointu. Wiersz
`source=repo` pokazuje stan remote, a wiersz `source=tokens.json` pokazuje stan
lokalnego store. Kolumna `sync` pokazuje `*` przy aktualnym zrodle prawdy dla
pary repo/store. Przy konflikcie pokazuje `!`.
Kolumna `id` pokazuje identyfikator miejsca przechowywania tokena: dla repo jest
to nazwa remota, na przyklad `r1`, a dla store nazwa tokenu, na przyklad `t1`.
Endpoint jest rozbity na `server`, `scheme`, `host` i `port`.
Maski uprawnien:
- `scope` ma pozycje `awrop`: `all`, `write:repository`, `read:repository`, organization scope, `public-only`
- `org` ma pozycje `oawrc`: owner, admin, write, read, create repo
- `repo` ma pozycje `oawr`: owner, admin, write, read
- `+` oznacza wlaczone, `-` wylaczone, `?` nie wczytano, `!` blad wczytania
Przyklad:
```bash
./rvctl tokens scan
./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03
./rvctl tokens scan --verbose
```
## `tokens read`
@@ -385,15 +381,14 @@ item<TAB>value
repo_root<TAB>...
token_path<TAB>...
items
item source id server scheme host port credential repo_user store_user token sync
---- ----------- ---------- ------ ------ --------------- ----- ---------- --------- ---------- ----- ----
1 repo r1 gitea http 77.90.8.171 3001 plain u1 no
1 tokens.json t1 gitea http 77.90.8.171 3001 store u1 u1 yes *
tokens
item server host org repo user remote token_ref token scope org repo
---- ------ ------------- --------- ----------- ---- ------ --------- ------------ ------ ------ ------
1 gitea 77.90.8.171 edu-tools rv-launcher u1 r1 t1 * e59cc...13be +---+ +++++ ++++
status
item<TAB>value
store_ahead<TAB>1
in_sync<TAB>1
```
Przyklad:
+27 -28
View File
@@ -88,49 +88,48 @@ Dzialanie:
- laczy oba zrodla po endpoincie serwera
- zapisuje znalezione tokeny do `tokens.json`
- zapisuje je per endpoint serwera
- wypisuje domyslnie tylko waska tabele `items`
- z `--verbose` dopisuje diagnostyczne sekcje `context` oraz `scan`
- wypisuje tabele `tokens`
Tabela `items` porownuje dwa zrodla dla tego samego endpointu. Ten sam `item`
moze miec dwa wiersze:
Tabela `tokens` pokazuje jeden logiczny wiersz na token. Remote i `tokens.json`
sa laczone po endpoincie, userze i wartosci tokena.
- `source=repo` - stan remote URL-i w repo
- `source=tokens.json` - stan lokalnego store tokenow
Znacznik w `token_ref`:
Dzieki temu widac, dla ktorego endpointu token jest w remote, a dla ktorego
jest tylko w `tokens.json`.
- `*` - token jest w remote i `tokens.json`, uprawnienia zostaly wczytane
- `R` - token jest tylko w remote
- `S` - token jest tylko w `tokens.json`
- `!` - blad wczytania uprawnien dla sparowanego tokena
Kolumny w tabeli `items`:
Kolumny w tabeli `tokens`:
- `item` - numer porownywanego endpointu
- `source` - `repo` albo `tokens.json`
- `id` - identyfikator miejsca tokena: remote `r1` dla repo albo token `t1` dla `tokens.json`
- `item` - numer wiersza tokena
- `server` - typ serwera, na przyklad `gitea`
- `scheme` - schemat URL, na przyklad `http`
- `host` - host endpointu, na przyklad `77.90.8.171`
- `port` - port endpointu, na przyklad `3001`
- `credential` - typ wpisu: `auth`, `plain`, `store` albo `missing`
- `repo_user` - login odczytany z remote URL-i repo
- `store_user` - login odczytany z `tokens.json`
- `token` - `yes` albo `no`, czyli czy dane zrodlo ma token dla endpointu
- `sync` - `*` przy zrodle prawdy albo `!` przy konflikcie
- `org` - organizacja z remote URL
- `repo` - repo z remote URL
- `user` - login wlasciciela tokena
- `remote` - nazwa remota, na przyklad `r1`
- `token_ref` - nazwa tokena z markerem po prawej stronie
- `token` - zamaskowana wartosc tokena
- `scope` - maska scope tokena `awrop`
- `org` - maska praw w organizacji `oawrc`
- `repo` - maska praw w repo `oawr`
Tabela nie wypisuje sekretu tokena. Jezeli token jest w remote, `id` pokazuje
nazwe remota, na przyklad `r1`. Jezeli token jest w `tokens.json`, `id`
pokazuje nazwe tokenu, na przyklad `t1`.
Maski uprawnien:
Zasada znacznika `sync`:
- `+` - flaga wlaczona
- `-` - flaga wylaczona
- `?` - nie wczytano, na przyklad dla `R` albo `S`
- `!` - blad wczytania
- `in_sync`, `store_only`, `store_ahead` - `*` przy `tokens.json`
- `repo_only`, `repo_ahead` - `*` przy `repo`
- `diverged` - `!`, bo nie ma jednoznacznego zrodla prawdy
Tabela nie wypisuje sekretu tokena wprost. Kolumna `token` pokazuje skrot, na
przyklad `e59cc...13be`.
Przyklad:
```bash
./rvctl tokens scan
./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03
./rvctl tokens scan --verbose
```
### `tokens read`
@@ -170,7 +169,7 @@ Wynik ma ten sam model porownania co `tokens scan`, ale nie zapisuje zmian:
- liczbe endpointow w store
- laczna unie endpointow
- statusy zgodnosci, na przyklad `in_sync`, `store_ahead`, `repo_ahead`
- tabele `items`, w ktorej repo i `tokens.json` sa osobnymi wierszami tego samego itemu
- tabele `tokens`
Przyklad:
+340 -159
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import os
@@ -13,6 +14,8 @@ import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib import error as urlerror
from urllib import request as urlrequest
from urllib.parse import quote, unquote, urlsplit, urlunsplit
@@ -244,6 +247,17 @@ def server_type_from_split(config: WorkspaceConfig, split_url) -> str:
return "unknown"
def repo_parts_from_url(remote_url: str) -> tuple[str, str]:
split_url = urlsplit(remote_url)
repo_path = unquote(split_url.path).strip("/")
if repo_path.endswith(".git"):
repo_path = repo_path[:-4]
path_parts = [part for part in repo_path.split("/") if part]
if len(path_parts) < 2:
return "", ""
return path_parts[0], path_parts[1]
def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | None:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"}:
@@ -252,12 +266,15 @@ def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | Non
return None
endpoint, port_value = endpoint_from_split(split_url)
org_name, repo_name = repo_parts_from_url(remote_url)
return {
"endpoint": endpoint,
"type": server_type_from_split(config, split_url),
"scheme": split_url.scheme,
"host": split_url.hostname,
"port": port_value,
"org": org_name,
"repo": repo_name,
}
@@ -455,8 +472,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
"remote": remote_name,
"endpoint": "",
"type": "unsupported",
"org": "",
"repo": "",
"url_kind": "unsupported",
"user": "",
"token_value": "",
"result": "ignored",
}
)
@@ -471,8 +491,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "plain",
"user": "",
"token_value": "",
"result": "no_credentials",
}
)
@@ -486,8 +509,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "auth",
"user": user_name,
"token_value": token_value,
"result": "found",
}
)
@@ -575,6 +601,8 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup
"auth_urls": 0,
"plain_urls": 0,
"users": {},
"tokens": [],
"plain_remotes": [],
},
)
entry["remotes"].add(remote_name)
@@ -586,10 +614,26 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup
credentials = remote_credentials(remote_url)
if credentials is None:
entry["plain_urls"] += 1
entry["plain_remotes"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
}
)
continue
entry["auth_urls"] += 1
user_name, token_value = credentials
entry["tokens"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
"user": user_name,
"token_value": token_value,
}
)
user_entry = entry["users"].setdefault(user_name, {"tokens": {}})
tokens = user_entry.setdefault("tokens", {})
if token_value not in tokens.values():
@@ -657,7 +701,7 @@ def print_token_context(
print(f"token_path\t{config.token_path}")
print(f"repo_endpoints\t{len(repo_servers)}")
print(f"tokens_endpoints\t{len(store_servers)}")
print(f"items\t{endpoint_count}")
print(f"tokens\t{endpoint_count}")
def print_status_counts(status_counts: dict[str, int]) -> None:
@@ -667,19 +711,21 @@ def print_status_counts(status_counts: dict[str, int]) -> None:
print(f"{status_name}\t{status_counts[status_name]}")
TOKEN_ITEM_COLUMNS = [
("item", 4),
("source", 11),
("id", 10),
("server", 6),
("scheme", 6),
("host", 15),
("port", 5),
("credential", 10),
("repo_user", 9),
("store_user", 10),
("token", 5),
("sync", 4),
TOKEN_REF_WIDTH = 9
TOKEN_COLUMNS = [
("item", "item", 4),
("server", "server", 6),
("host", "host", 13),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("remote", "remote", 6),
("token_ref", "token_ref", TOKEN_REF_WIDTH),
("token", "token", 12),
("scope_mask", "scope", 6),
("org_mask", "org", 6),
("repo_mask", "repo", 6),
]
@@ -691,24 +737,23 @@ def fit_cell(value: str, width: int) -> str:
return f"{value[:width - 3]}..."
def print_token_item_table(rows: list[dict[str, str]]) -> None:
print("items")
print(" ".join(fit_cell(name, width) for name, width in TOKEN_ITEM_COLUMNS).rstrip())
print(" ".join("-" * width for _, width in TOKEN_ITEM_COLUMNS).rstrip())
def print_fixed_table(title: str, columns: list[tuple[str, str, int]], rows: list[dict[str, str]]) -> None:
print(title)
print(" ".join(fit_cell(label, width) for _, label, width in columns).rstrip())
print(" ".join("-" * width for _, _, width in columns).rstrip())
for row in rows:
print(" ".join(fit_cell(row.get(name, ""), width) for name, width in TOKEN_ITEM_COLUMNS).rstrip())
print(" ".join(fit_cell(row.get(name, ""), width) for name, _, width in columns).rstrip())
def sync_mark(status_name: str, source: str) -> str:
if status_name == "diverged":
return "!"
if status_name in {"in_sync", "store_only", "store_ahead"}:
truth_source = "tokens.json"
elif status_name in {"repo_only", "repo_ahead"}:
truth_source = "repo"
else:
truth_source = ""
return "*" if source == truth_source else ""
def short_secret(secret_value: str) -> str:
if len(secret_value) <= 12:
return mask_secret(secret_value)
return f"{secret_value[:5]}...{secret_value[-4:]}"
def format_token_ref(token_name: str, marker: str) -> str:
name_width = TOKEN_REF_WIDTH - 2
return f"{fit_cell(token_name, name_width)}{marker:>2}"
def users_label(server_entry: dict | None) -> str:
@@ -741,6 +786,191 @@ def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry:
return values
def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for endpoint, server_entry in sorted(store_servers.items()):
users = server_entry.get("users", {})
for user_name in sorted(users):
user_entry = users[user_name]
if not isinstance(user_entry, dict):
continue
for token_name, token_value in sorted(user_entry.get("tokens", {}).items()):
if isinstance(token_value, str):
rows.append(
{
"endpoint": endpoint,
"user": str(user_name),
"token_name": str(token_name),
"token_value": token_value,
}
)
return rows
def repo_token_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> list[dict[str, str]]:
if scan_rows is not None:
return [
{
"endpoint": row["endpoint"],
"remote": row["remote"],
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
for row in scan_rows
if row.get("endpoint") and row.get("url_kind") == "auth" and row.get("token_value")
]
rows: list[dict[str, str]] = []
for endpoint in endpoint_names:
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []):
rows.append(
{
"endpoint": endpoint,
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
)
return rows
def fallback_project_by_endpoint(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> dict[str, dict[str, str]]:
fallback: dict[str, dict[str, str]] = {}
for row in scan_rows or []:
endpoint = row.get("endpoint", "")
if endpoint and endpoint not in fallback:
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
for endpoint in endpoint_names:
if endpoint in fallback:
continue
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []) + repo_entry.get("plain_remotes", []):
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
break
return fallback
def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: bool = False) -> object:
headers = {"Accept": "application/json"}
if basic:
raw_credentials = f"{user_name}:{token_value}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw_credentials).decode("ascii")
else:
headers["Authorization"] = f"token {token_value}"
request = urlrequest.Request(api_url, headers=headers)
with urlrequest.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def scope_mask(scopes: list[str]) -> str:
scope_set = {scope.lower() for scope in scopes}
return "".join(
[
"+" if "all" in scope_set else "-",
"+" if "write:repository" in scope_set else "-",
"+" if "read:repository" in scope_set else "-",
"+" if {"read:organization", "write:organization"} & scope_set else "-",
"+" if "public-only" in scope_set else "-",
]
)
def org_permission_mask(org_permissions: dict) -> str:
return "".join(
[
"+" if org_permissions.get("is_owner") else "-",
"+" if org_permissions.get("is_admin") else "-",
"+" if org_permissions.get("can_write") else "-",
"+" if org_permissions.get("can_read") else "-",
"+" if org_permissions.get("can_create_repository") else "-",
]
)
def repo_permission_mask(repo_permissions: dict) -> str:
permission = str(repo_permissions.get("permission", "none")).lower()
levels = {
"owner": "++++",
"admin": "-+++",
"write": "--++",
"read": "---+",
}
return levels.get(permission, "----")
def load_gitea_authz(endpoint: str, user_name: str, token_name: str, token_value: str, owner: str, repo_name: str) -> dict:
api_base = endpoint.rstrip("/") + "/api/v1"
result = {
"ok": False,
"scope_mask": "!!!!!",
"org_mask": "!!!!!",
"repo_mask": "!!!!",
}
try:
tokens_url = f"{api_base}/users/{quote(user_name, safe='')}/tokens"
token_list = api_get_json(tokens_url, token_value, user_name=user_name, basic=True)
token_last8 = token_value[-8:]
token_entry = next(
(
entry
for entry in token_list
if isinstance(entry, dict) and entry.get("token_last_eight") == token_last8
),
None,
)
scopes = token_entry.get("scopes", []) if isinstance(token_entry, dict) else []
if not isinstance(scopes, list):
scopes = []
result["scope_mask"] = scope_mask([str(scope) for scope in scopes])
if owner:
org_url = f"{api_base}/users/{quote(user_name, safe='')}/orgs/{quote(owner, safe='')}/permissions"
org_permissions = api_get_json(org_url, token_value)
if isinstance(org_permissions, dict):
result["org_mask"] = org_permission_mask(org_permissions)
if owner and repo_name:
repo_url = (
f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}"
f"/collaborators/{quote(user_name, safe='')}/permission"
)
repo_permissions = api_get_json(repo_url, token_value)
if isinstance(repo_permissions, dict):
result["repo_mask"] = repo_permission_mask(repo_permissions)
result["ok"] = True
return result
except (OSError, ValueError, urlerror.URLError, urlerror.HTTPError):
return result
def print_token_item_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
@@ -749,132 +979,100 @@ def print_token_item_rows(
) -> dict[str, int]:
status_names = ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]
status_counts = {status_name: 0 for status_name in status_names}
scan_rows_by_endpoint: dict[str, list[dict[str, str]]] = {}
for row in scan_rows or []:
endpoint = row.get("endpoint", "")
if endpoint:
scan_rows_by_endpoint.setdefault(endpoint, []).append(row)
item_rows: list[dict[str, str]] = []
for index, endpoint in enumerate(endpoint_names, start=1):
item_id = str(index)
for endpoint in endpoint_names:
repo_entry = repo_servers.get(endpoint)
store_entry = store_servers.get(endpoint)
status_name, _, _ = compare_server_entries(repo_entry, store_entry)
if status_name in status_counts:
status_counts[status_name] += 1
endpoint_values = endpoint_column_values(endpoint, repo_entry, store_entry)
repo_users_label = users_label(repo_entry)
store_users_label = users_label(store_entry)
repo_rows = scan_rows_by_endpoint.get(endpoint, [])
if repo_rows:
for row in repo_rows:
token_label = "remote" if row["url_kind"] == "auth" else ""
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": row["remote"],
**endpoint_values,
"credential": row["url_kind"],
"repo_user": row["user"],
"store_user": store_users_label,
"token": "yes" if token_label else "no",
"sync": sync_mark(status_name, "repo"),
store_rows = token_store_rows(store_servers)
store_by_value: dict[tuple[str, str, str], dict[str, str]] = {}
for row in store_rows:
store_by_value.setdefault((row["endpoint"], row["user"], row["token_value"]), row)
endpoint_values_by_name = {
endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint))
for endpoint in endpoint_names
}
)
elif repo_entry is None:
item_rows.append(
project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows)
authz_cache: dict[tuple[str, str, str, str, str], dict] = {}
used_store_keys: set[tuple[str, str, str, str]] = set()
token_rows: list[dict[str, str]] = []
def append_token_row(
endpoint: str,
user_name: str,
remote_name: str,
token_name: str,
token_value: str,
marker: str,
owner: str,
repo_name: str,
authz: dict | None = None,
) -> None:
endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None))
token_rows.append(
{
"item": item_id,
"source": "repo",
**endpoint_values,
"credential": "missing",
"store_user": store_users_label,
"token": "no",
"sync": sync_mark(status_name, "repo"),
}
)
else:
repo_kind, repo_result = repo_result_for_entry(repo_entry)
remote_names = ",".join(sorted(repo_entry.get("remotes", set())))
repo_users = token_name_rows(repo_entry.get("users", {}))
if repo_users:
for user_name, _ in repo_users:
token_label = "remote" if repo_kind == "auth" else ""
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": remote_names,
**endpoint_values,
"credential": repo_kind,
"repo_user": user_name,
"store_user": store_users_label,
"token": "yes" if token_label else "no",
"sync": sync_mark(status_name, "repo"),
}
)
else:
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": remote_names,
**endpoint_values,
"credential": repo_kind,
"store_user": store_users_label,
"token": "yes" if repo_result == "present" else "no",
"sync": sync_mark(status_name, "repo"),
"item": str(len(token_rows) + 1),
"server": endpoint_values["server"],
"host": endpoint_values["host"],
"owner": owner,
"repo_name": repo_name,
"user": user_name,
"remote": remote_name,
"token_ref": format_token_ref(token_name, marker),
"token": short_secret(token_value),
"scope_mask": authz.get("scope_mask", "?????") if authz else "?????",
"org_mask": authz.get("org_mask", "?????") if authz else "?????",
"repo_mask": authz.get("repo_mask", "????") if authz else "????",
}
)
if store_entry is None:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
**endpoint_values,
"credential": "missing",
"repo_user": repo_users_label,
"token": "no",
"sync": sync_mark(status_name, "tokens.json"),
}
for row in repo_token_rows(endpoint_names, repo_servers, scan_rows):
endpoint = row["endpoint"]
token_value = row["token_value"]
store_row = store_by_value.get((endpoint, row["user"], token_value))
token_name = store_row["token_name"] if store_row else ""
marker = "R"
authz = None
if store_row:
used_store_keys.add((endpoint, store_row["user"], store_row["token_name"], token_value))
authz_key = (endpoint, row["user"], token_value, row["owner"], row["repo_name"])
authz = authz_cache.get(authz_key)
if authz is None:
authz = load_gitea_authz(endpoint, row["user"], token_name, token_value, row["owner"], row["repo_name"])
authz_cache[authz_key] = authz
marker = "*" if authz.get("ok") else "!"
append_token_row(
endpoint,
row["user"],
row["remote"],
token_name,
token_value,
marker,
row["owner"],
row["repo_name"],
authz if marker == "*" else None,
)
for row in store_rows:
store_key = (row["endpoint"], row["user"], row["token_name"], row["token_value"])
if store_key in used_store_keys:
continue
store_users = token_name_rows(store_entry.get("users", {}))
if not store_users:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
**endpoint_values,
"credential": "store",
"repo_user": repo_users_label,
"token": "no",
"sync": sync_mark(status_name, "tokens.json"),
}
)
continue
for user_name, token_name in store_users:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
"credential": "store",
"id": token_name,
**endpoint_values,
"repo_user": repo_users_label,
"store_user": user_name,
"token": "yes" if token_name else "no",
"sync": sync_mark(status_name, "tokens.json"),
}
fallback = project_fallback.get(row["endpoint"], {})
append_token_row(
row["endpoint"],
row["user"],
"",
row["token_name"],
row["token_value"],
"S",
fallback.get("owner", ""),
fallback.get("repo_name", ""),
)
print_token_item_table(item_rows)
print_fixed_table("tokens", TOKEN_COLUMNS, token_rows)
return status_counts
@@ -972,22 +1170,6 @@ def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
token_data = load_token_store(config)
store_servers = token_data.get("servers", {})
endpoint_names = sorted(set(repo_servers) | set(store_servers))
if args.verbose:
print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names))
print()
print("scan")
print("item\tvalue")
print(f"remotes\t{report['remotes']}")
print(f"urls\t{report['urls']}")
print(f"scanned\t{report['scanned']}")
print(f"auth_urls\t{report['auth_urls']}")
print(f"plain_urls\t{report['plain_urls']}")
print(f"unsupported_urls\t{report['unsupported_urls']}")
print(f"found\t{report['found']}")
print(f"added\t{report['added']}")
print(f"servers\t{report['servers']}")
print()
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
@@ -1386,7 +1568,7 @@ def print_main_overview(config_path: Path) -> None:
print_table(
["step", "command", "result"],
[
["1", "./rvctl tokens scan", "pair repo remotes with tokens.json entries"],
["1", "./rvctl tokens scan", "print token table from repo remotes and tokens.json"],
["2", "./rvctl list-series", "choose a series"],
["3", "./rvctl list-cards inf", "choose a card"],
["4", "./rvctl tmux-container inf <card>", "start the working container"],
@@ -1413,7 +1595,7 @@ def print_tokens_overview() -> None:
print_table(
["command", "common options", "direction", "purpose"],
[
["scan", "[--repo PATH]", "repo + tokens.json", "pair endpoints and show where token refs are stored"],
["scan", "[--repo PATH]", "repo + tokens.json", "print token table with remote/store marker and auth masks"],
["read", "[--server ENDPOINT]", "tokens.json", "show servers, users and token names"],
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"],
["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"],
@@ -1524,7 +1706,6 @@ def build_parser() -> argparse.ArgumentParser:
help="Scan repo remotes and tokens.json, then print paired endpoint rows.",
)
add_repo_option(tokens_scan_parser)
tokens_scan_parser.add_argument("--verbose", action="store_true", help="Print context and scan counters before item rows.")
tokens_read_parser = tokens_subparsers.add_parser(
"read",