From 8c0f95d996ab24d12ddb545ce36c6056b8da15b7 Mon Sep 17 00:00:00 2001 From: mpabi Date: Sun, 26 Apr 2026 21:33:14 +0200 Subject: [PATCH] Use token list schema for token store --- README.md | 42 ++- doc/tokens.md | 311 ++++++----------- doc/tokens.schema.json | 108 ++++++ rvctl.py | 758 ++++++++++++++++++++++++++--------------- 4 files changed, 712 insertions(+), 507 deletions(-) create mode 100644 doc/tokens.schema.json diff --git a/README.md b/README.md index e1f561f..d7b7f1b 100644 --- a/README.md +++ b/README.md @@ -112,25 +112,32 @@ Minimalny format: ```json { - "version": 2, - "servers": { - "http://77.90.8.171:3001": { - "type": "gitea", - "scheme": "http", - "host": "77.90.8.171", - "port": 3001, - "users": { - "u1": { - "tokens": { - "t1": "TU_WSTAW_TOKEN" - } + "version": 3, + "tokens": [ + { + "token_id": "t1", + "value": "TU_WSTAW_TOKEN", + "server": { + "type": "gitea", + "endpoint": "http://77.90.8.171:3001", + "scheme": "http", + "host": "77.90.8.171", + "port": 3001 + }, + "remotes": [ + { + "name": "r1", + "org": "edu-tools", + "repo": "rv-launcher" } - } + ] } - } + ] } ``` +Pelny schemat pliku jest w `doc/tokens.schema.json`. + Plik powinien byc lokalny, niewersjonowany i miec prawa `600`. Przyklad: @@ -162,10 +169,11 @@ logiczny remote tokena. 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 `????`. -Zapis z remote do `tokens.json` robi dopiero `tokens update --from remotes`. +Zapis z remote do `tokens.json` oraz odczyt `valid` i uprawnien z API robi +dopiero `tokens update --from remotes`. Kolumna `valid` oznacza, czy token zostal zaakceptowany przez API teraz. -Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` pokaze date -wygasniecia; bez daty poprawny token pokazuje `forever`. +Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` moze pokazac +date wygasniecia; bez daty poprawny token pokazuje `forever`. ## Fetch i switch diff --git a/doc/tokens.md b/doc/tokens.md index baa43cb..580c577 100644 --- a/doc/tokens.md +++ b/doc/tokens.md @@ -1,178 +1,139 @@ # Tokens -Plik opisuje aktualny model pracy z tokenami w launcherze. +Plik opisuje model pracy z tokenami w launcherze. ## Zrodlo prawdy Sa dwa miejsca, w ktorych moga byc zapisane tokeny: -- remote URL-e w repo, na przyklad `http://LOGIN:TOKEN@host/org/repo.git` +- remote URL-e w repo, na przyklad `http://t1:SECRET@host/org/repo.git` - lokalny plik `~/dev/workspace/rv/tokens/tokens.json` -Jesli remote URL zawiera `LOGIN:TOKEN@...`, to remote jest zrodlem prawdy. -Launcher moze wtedy pokazac stan przez `tokens scan` albo jawnie zapisac token -do `tokens.json` przez `tokens update --from remotes`. - -Jesli remote nie ma tokena, `tokens.json` moze byc uzyty jako lokalny store -przy operacjach `tokens write` i `tokens update --from store`. +`tokens scan` tylko porownuje oba zrodla i niczego nie zapisuje. +`tokens update --from remotes` czyta remote URL-e, zapisuje tokeny do +`tokens.json` i wzbogaca je danymi z API, jezeli token dziala. +`tokens update --from store` zapisuje wybrany token z `tokens.json` do remote +URL-a repo. ## Format `tokens.json` -Tokeny sa trzymane per endpoint serwera, a dopiero pod nim per user i token: +Aktualny format to `version: 3`. Glownym rekordem jest token. `server`, `user`, +`valid`, `scope` i `remotes` sa atrybutami tego tokena. ```json { - "version": 2, - "servers": { - "http://77.90.8.171:3001": { - "type": "gitea", - "scheme": "http", - "host": "77.90.8.171", - "port": 3001, - "users": { - "u1": { - "tokens": { - "t1": "SECRET", - "t2": { - "value": "SECRET", - "remote": "r1", - "org": "edu-tools", - "repo": "rv-launcher", - "expires_at": "2026-05-01T12:00:00" - } - } + "version": 3, + "tokens": [ + { + "token_id": "t1", + "value": "SECRET", + "server": { + "type": "gitea", + "endpoint": "http://77.90.8.171:3001", + "scheme": "http", + "host": "77.90.8.171", + "port": 3001 + }, + "user": "u1", + "valid": "forever", + "scope": "+---+", + "remotes": [ + { + "name": "r1", + "org": "edu-tools", + "repo": "rv-launcher", + "org_perm": "+++++", + "repo_perm": "++++" } - } + ] } - } + ] } ``` -To pozwala odroznic: +Schemat JSON jest w pliku `doc/tokens.schema.json`. -- typ serwera, na przyklad `gitea`, `github`, `gitlab`, `unknown` -- endpoint, czyli `scheme + host + port` -- uzytkownikow na danym serwerze -- wiele tokenow dla jednego usera -- nazwe remota, ktora jest identyfikatorem parowania z repo -- opcjonalne `org` i `repo` zapamietane z remote URL-a -- opcjonalna date wygasniecia `expires_at` dla tokena +## Pola -## Skanowanie remota +Pola synchronizowane z remote URL-a: -Przy `tokens scan` launcher: +- `server.endpoint` - endpoint serwera, na przyklad `http://77.90.8.171:3001` +- `server.type` - typ serwera, na przyklad `gitea`, `github`, `gitlab`, `unknown` +- `token_id` - identyfikator z lewej strony URL-a, na przyklad `t1` w `http://t1:SECRET@...` +- `value` - sekret tokena +- `remotes[].name` - nazwa remota, na przyklad `r1` +- `remotes[].org` - organizacja z URL-a +- `remotes[].repo` - repo z URL-a -- czyta wszystkie remote URL-e w repo -- czyta `tokens.json` -- laczy remote i store w pary po endpoincie serwera i nazwie remota -- wybiera tylko `http` i `https` -- jesli URL ma `LOGIN:TOKEN@...`, wyciaga login i token -- niczego nie zapisuje do `tokens.json` +Pola wzbogacane przez API przy `tokens update --from remotes`: -Endpoint jest liczony z: +- `user` - login wlasciciela tokena odczytany z API +- `valid` - `forever`, data `expires_at`, `invalid`, `?` albo `!` +- `scope` - maska scope tokena +- `remotes[].org_perm` - maska praw w organizacji +- `remotes[].repo_perm` - maska praw w repo -- scheme -- host -- port +## Porownanie -Przy `tokens update --from remotes` launcher zapisuje tez metadane serwera: +Porownanie z `git remote -v` jest robione po: -- `type` -- `scheme` -- `host` -- `port` +```text +server.endpoint + remote.name + token_id + value + org + repo +``` + +Znacznik w kolumnie `token_ref`: + +- `*` - remote i `tokens.json` sa zgodne +- `R` - wpis istnieje tylko w remote URL-u +- `S` - wpis istnieje tylko w `tokens.json` +- `!` - remote i `tokens.json` sa zgodne, ale zapisany token jest `invalid` albo ma blad walidacji + +Bez `*` albo `!` kolumny `valid`, `scope`, `org` i `repo` w raporcie maja +wartosc `?`, bo launcher nie pokazuje metadanych API dla niesparowanych wpisow. + +## Maski uprawnien + +Naglowki masek: + +- `scope`: `awrop-` +- `org`: `oawrc-` +- `repo`: `oawr--` + +Znaki w wartosciach: + +- `+` - flaga wlaczona +- `-` - flaga wylaczona +- `?` - nie wczytano +- `!` - blad wczytania ## Komendy ### `tokens scan` -Zrodla: - -```text -repo + tokens.json -``` - -Dzialanie: - -- skanuje remote URL-e w repo -- czyta wpisy z `tokens.json` -- laczy oba zrodla po endpoincie serwera i nazwie remota -- wypisuje tabele `tokens` - -Tabela `tokens` pokazuje jeden logiczny wiersz na remote tokena. Remote i -`tokens.json` sa laczone po endpoincie serwera oraz kolumnie `remote`. - -Znacznik w `token_ref`: - -- `*` - token jest w remote i `tokens.json`, remote/user/token sa zgodne, 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 `tokens`: - -- `item` - numer wiersza tokena -- `server` - typ serwera, na przyklad `gitea` -- `proto` - protokol endpointu, na przyklad `http` -- `host` - host endpointu razem z portem, na przyklad `77.90.8.171:3001` -- `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 -- `valid` - `forever`, lokalne `expires_at`, `invalid`, `?` albo `!` -- `scope` - maska scope tokena `awrop` -- `org` - maska praw w organizacji `oawrc` -- `repo` - maska praw w repo `oawr` - -Maski uprawnien: - -- `+` - flaga wlaczona -- `-` - flaga wylaczona -- `?` - nie wczytano, na przyklad dla `R` albo `S` -- `!` - blad wczytania - -Tabela nie wypisuje sekretu tokena wprost. Kolumna `token` pokazuje skrot, na -przyklad `e59cc...13be`. - -`valid` jest liczone tak: - -- `forever` - API akceptuje token i nie ma lokalnego `expires_at` -- `2026-05-01T12:00:00` - API akceptuje token i taka data jest zapisana w `tokens.json` -- `invalid` - API odrzuca token albo lokalne `expires_at` jest w przeszlosci -- `?` - token nie jest sparowany jako `*`, wiec nie sprawdzamy uprawnien -- `!` - blad sprawdzania API - -`tokens scan` jest read-only. Jezeli token jest tylko w remote, tabela pokaze -`R` i maski `?????`/`????`. Dopiero jawne `tokens update --from remotes` -zapisuje token oraz metadane `remote`, `org` i `repo` do `tokens.json`. - -Przyklad: +Read-only. Czyta remote URL-e i `tokens.json`, a potem wypisuje tabele `tokens`. +Nie tworzy i nie modyfikuje `tokens.json`. ```bash ./rvctl tokens scan ./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 ``` +### `tokens update --from remotes` + +Kopiuje tokeny z remote URL-i repo do `tokens.json`, zapisuje rekordy w formacie +v3 i probuje pobrac pola API: `user`, `valid`, `scope`, `org_perm`, +`repo_perm`. + +```bash +./rvctl tokens update --from remotes --repo ~/dev/workspace/rv/tools/rv-launcher +./rvctl tokens update --from remotes --dry-run +``` + +`--dry-run` dziala jak read-only raport i niczego nie zapisuje. + ### `tokens read` -Pokazuje szczegolowa zawartosc `tokens.json`. - -Wynik zawiera: - -- endpoint -- type -- scheme -- host -- port -- users -- tokens -- remote/org/repo przy tokenie, jezeli sa zapisane - -Oraz liste userow i nazw tokenow dla kazdego endpointu. - -Przyklad: +Pokazuje zawartosc `tokens.json`. ```bash ./rvctl tokens read @@ -182,20 +143,7 @@ Przyklad: ### `tokens stats` -Pokazuje statystyki per endpoint serwera i porownuje dwa zrodla: - -- endpointy znalezione w remote URL-ach repo -- endpointy zapisane w `tokens.json` - -Wynik ma ten sam model porownania co `tokens scan`, ale nie zapisuje zmian: - -- liczbe endpointow w repo -- liczbe endpointow w store -- laczna unie endpointow -- statusy zgodnosci, na przyklad `in_sync`, `store_ahead`, `repo_ahead` -- tabele `tokens` - -Przyklad: +Pokazuje kontekst, tabele `tokens` i podsumowanie statusow endpointow. ```bash ./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 @@ -203,78 +151,21 @@ Przyklad: ### `tokens write` -Kierunek: - -```text -tokens.json -> repo -``` - -Dzialanie: - -- bierze token z `tokens.json` -- wybiera endpoint, usera i token -- wpisuje dane auth do wybranego remota repo - -Przyklad: +Zapisuje wybrany token z `tokens.json` do remote URL-a repo. ```bash ./rvctl tokens write \ --repo ~/dev/workspace/rv/series/inf/03 \ --remote r1 \ --server http://77.90.8.171:3001 \ - --user u1 \ --token-name t1 ``` -### `tokens update` +### `tokens update --from store` -Uruchamia synchronizacje w zadanym kierunku. - -Dozwolone kierunki: - -- `tokens update --from remotes` -- `tokens update --from store` - -`tokens update --from remotes` kopiuje tokeny z remote URL-i repo do -`tokens.json`. Z `--dry-run` dziala jak read-only raport. `tokens update ---from store` kopiuje wybrany token z `tokens.json` do remote URL-a repo. - -Przyklad: +Alias kierunkowy na zapis store -> remote. Uzywa tych samych opcji co +`tokens write`. ```bash -./rvctl tokens update --from remotes --repo ~/dev/workspace/rv/series/inf/03 -./rvctl tokens update --from store --repo ~/dev/workspace/rv/series/inf/03 --remote r1 --server http://77.90.8.171:3001 --user u1 --token-name t1 +./rvctl tokens update --from store --repo PATH --remote r1 --server http://77.90.8.171:3001 --token-name t1 ``` - -## Konflikty - -Domyslnie launcher nie zgaduje przy konflikcie. - -Jesli: - -- token jest w repo, ale nie ma go w `tokens.json` - uzyj `tokens update --from remotes` -- token jest w `tokens.json`, ale nie ma go w repo - uzyj `tokens write` -- token jest i tu, i tu, ale wartosci sa rozne - wybierz kierunek jawnie przez `tokens update --from ...` - -Przy `tokens update --from remotes` mozna uzyc: - -- `--dry-run` - -Przy `tokens write` i `tokens update --from store` mozna uzyc: - -- `--replace` -- `--dry-run` - -## Rekomendacja - -Najbezpieczniejszy model pracy: - -- `tokens scan` do zczytywania danych z remote'ow -- `tokens update --from remotes` do jawnego zapisania tokenow z remote'ow w `tokens.json` -- `tokens read` do podgladu store -- `tokens stats` do zbiorczego przegladu per endpoint -- `tokens write` do jawnego wpisania auth do remota -- `tokens update --from ...` tylko z jawnym kierunkiem diff --git a/doc/tokens.schema.json b/doc/tokens.schema.json new file mode 100644 index 0000000..7ef8226 --- /dev/null +++ b/doc/tokens.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://edu-tools.local/rv-launcher/tokens.schema.json", + "title": "RV launcher tokens", + "type": "object", + "additionalProperties": false, + "required": ["version", "tokens"], + "properties": { + "version": { + "const": 3 + }, + "tokens": { + "type": "array", + "items": { + "$ref": "#/$defs/token" + } + } + }, + "$defs": { + "server": { + "type": "object", + "additionalProperties": false, + "required": ["type", "endpoint", "scheme", "host", "port"], + "properties": { + "type": { + "type": "string" + }, + "endpoint": { + "type": "string", + "format": "uri" + }, + "scheme": { + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 65535 + } + } + }, + "remote": { + "type": "object", + "additionalProperties": false, + "required": ["name", "org", "repo"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "org": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "org_perm": { + "type": "string", + "pattern": "^[+?!-]{5}$" + }, + "repo_perm": { + "type": "string", + "pattern": "^[+?!-]{4}$" + } + } + }, + "token": { + "type": "object", + "additionalProperties": false, + "required": ["token_id", "value", "server", "remotes"], + "properties": { + "token_id": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "server": { + "$ref": "#/$defs/server" + }, + "user": { + "type": "string" + }, + "valid": { + "type": "string" + }, + "scope": { + "type": "string", + "pattern": "^[+?!-]{5}$" + }, + "expires_at": { + "type": "string" + }, + "remotes": { + "type": "array", + "items": { + "$ref": "#/$defs/remote" + } + } + } + } + } +} diff --git a/rvctl.py b/rvctl.py index 4551281..74a0ec7 100644 --- a/rvctl.py +++ b/rvctl.py @@ -279,7 +279,7 @@ def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | Non def empty_token_store() -> dict: - return {"version": 2, "servers": {}} + return {"version": 3, "tokens": []} def next_token_name(tokens: dict[str, str]) -> str: @@ -289,6 +289,45 @@ def next_token_name(tokens: dict[str, str]) -> str: return f"t{index}" +def server_record_from_info(server_info: dict) -> dict: + return { + "type": str(server_info.get("type", "unknown")), + "endpoint": str(server_info.get("endpoint", "")), + "scheme": str(server_info.get("scheme", "")), + "host": str(server_info.get("host", "")), + "port": server_info.get("port"), + } + + +def normalize_server_record(config: WorkspaceConfig, raw_server: dict, endpoint: str | None = None) -> dict | None: + if not isinstance(raw_server, dict): + return None + + endpoint_value = str(raw_server.get("endpoint") or endpoint or "") + if not endpoint_value: + return None + + split_endpoint = urlsplit(endpoint_value) + host_value = raw_server.get("host") or split_endpoint.hostname or "" + scheme_value = raw_server.get("scheme") or split_endpoint.scheme + port_value = raw_server.get("port") + if port_value is None and split_endpoint.port is not None: + port_value = split_endpoint.port + + server_type = raw_server.get("type") or "unknown" + if server_type == "unknown" and scheme_value and host_value: + server_url = endpoint_value + server_type = server_type_from_split(config, urlsplit(server_url)) + + return { + "type": str(server_type), + "endpoint": endpoint_value, + "scheme": str(scheme_value), + "host": str(host_value), + "port": port_value, + } + + def normalize_token_entry(raw_token_entry) -> str | dict | None: if isinstance(raw_token_entry, str): return raw_token_entry if raw_token_entry else None @@ -334,6 +373,86 @@ def token_value_exists(tokens: dict, token_value: str) -> bool: return any(token_entry_value(existing_token) == token_value for existing_token in tokens.values()) +def normalize_remote_record(raw_remote: dict) -> dict | None: + if not isinstance(raw_remote, dict): + return None + remote_name = raw_remote.get("name") or raw_remote.get("remote") + if not isinstance(remote_name, str) or not remote_name: + return None + + remote_record = {"name": remote_name, "org": "", "repo": ""} + for field_name in ["org", "repo", "org_perm", "repo_perm"]: + field_value = raw_remote.get(field_name) + if isinstance(field_value, str) and field_value: + remote_record[field_name] = field_value + return remote_record + + +def normalize_v3_token(config: WorkspaceConfig, raw_token: dict) -> dict | None: + if not isinstance(raw_token, dict): + return None + + token_id = raw_token.get("token_id") or raw_token.get("id") or raw_token.get("token_ref") + token_value = raw_token.get("value") or raw_token.get("token") + if not isinstance(token_id, str) or not token_id: + return None + if not isinstance(token_value, str) or not token_value: + return None + + server_record = normalize_server_record(config, raw_token.get("server", {})) + if server_record is None: + return None + + token_record = { + "token_id": token_id, + "value": token_value, + "server": server_record, + "remotes": [], + } + for field_name in ["user", "valid", "scope", "expires_at"]: + field_value = raw_token.get(field_name) + if isinstance(field_value, str) and field_value: + token_record[field_name] = field_value + + raw_remotes = raw_token.get("remotes", []) + if isinstance(raw_remotes, dict): + raw_remotes = [ + {"name": remote_name, **remote_value} + for remote_name, remote_value in raw_remotes.items() + if isinstance(remote_value, dict) + ] + if isinstance(raw_remotes, list): + for raw_remote in raw_remotes: + remote_record = normalize_remote_record(raw_remote) + if remote_record is not None: + token_record["remotes"].append(remote_record) + + return token_record + + +def append_token_record(token_data: dict, token_record: dict) -> None: + tokens = token_data.setdefault("tokens", []) + token_key = ( + token_record.get("server", {}).get("endpoint", ""), + token_record.get("token_id", ""), + token_record.get("value", ""), + ) + for existing_record in tokens: + existing_key = ( + existing_record.get("server", {}).get("endpoint", ""), + existing_record.get("token_id", ""), + existing_record.get("value", ""), + ) + if existing_key == token_key: + existing_remotes = existing_record.setdefault("remotes", []) + existing_remote_names = {remote.get("name", "") for remote in existing_remotes} + for remote_record in token_record.get("remotes", []): + if remote_record.get("name", "") not in existing_remote_names: + existing_remotes.append(remote_record) + return + tokens.append(token_record) + + def parse_datetime(raw_value: str) -> dt.datetime | None: if not raw_value: return None @@ -363,30 +482,7 @@ def valid_label(api_accepts_token: bool, expires_at: str) -> str: return "forever" -def ensure_server_entry(token_data: dict, server_info: dict) -> dict: - servers = token_data.setdefault("servers", {}) - endpoint = server_info["endpoint"] - server_entry = servers.setdefault( - endpoint, - { - "type": server_info["type"], - "scheme": server_info["scheme"], - "host": server_info["host"], - "port": server_info["port"], - "users": {}, - }, - ) - server_entry.setdefault("users", {}) - for field_name in ["type", "scheme", "host", "port"]: - if field_name not in server_entry or server_entry[field_name] in {"", None, "unknown"}: - server_entry[field_name] = server_info[field_name] - return server_entry - - def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> dict: - server_entry = ensure_server_entry(token_data, server_info) - server_users = server_entry.setdefault("users", {}) - for user_name, user_entry in raw_users.items(): if not isinstance(user_entry, dict): continue @@ -394,28 +490,48 @@ def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> d if not isinstance(raw_tokens, dict): continue - target_user = server_users.setdefault(str(user_name), {"tokens": {}}) - target_tokens = target_user.setdefault("tokens", {}) for token_name, raw_token_entry in raw_tokens.items(): token_entry = normalize_token_entry(raw_token_entry) if token_entry is None: continue token_value = token_entry_value(token_entry) - if token_value_exists(target_tokens, token_value): - continue - normalized_name = str(token_name) - if normalized_name in target_tokens and token_entry_value(target_tokens[normalized_name]) != token_value: - normalized_name = next_token_name(target_tokens) - target_tokens[normalized_name] = token_entry + token_record = { + "token_id": str(token_name), + "value": token_value, + "server": server_record_from_info(server_info), + "user": str(user_name), + "remotes": [], + } + expires_at = token_entry_expires_at(token_entry) + if expires_at: + token_record["expires_at"] = expires_at + + remote_name = token_entry_field(token_entry, "remote") + if remote_name: + remote_record = {"name": remote_name, "org": "", "repo": ""} + for source_name, target_name in [("org", "org"), ("repo", "repo")]: + source_value = token_entry_field(token_entry, source_name) + if source_value: + remote_record[target_name] = source_value + token_record["remotes"].append(remote_record) + + append_token_record(token_data, token_record) return token_data def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict: + if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list): + token_data = empty_token_store() + for raw_token in raw_data["tokens"]: + token_record = normalize_v3_token(config, raw_token) + if token_record is not None: + append_token_record(token_data, token_record) + return token_data + if "servers" in raw_data and isinstance(raw_data["servers"], dict): token_data = empty_token_store() - token_data["version"] = raw_data.get("version", 2) for endpoint, server_entry in raw_data["servers"].items(): if not isinstance(server_entry, dict): continue @@ -427,9 +543,7 @@ def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict: "host": server_entry.get("host", ""), "port": server_entry.get("port"), } - target_server = ensure_server_entry(token_data, base_server_info) copy_legacy_users(token_data, base_server_info, server_entry.get("users", {})) - target_server["users"] = token_data["servers"][str(endpoint)]["users"] return token_data token_data = empty_token_store() @@ -465,58 +579,119 @@ def write_token_store(config: WorkspaceConfig, token_data: dict) -> None: os.chmod(config.token_path, 0o600) -def token_entry_with_metadata(token_value: str, server_info: dict, remote_name: str) -> dict[str, str]: - token_entry = {"value": token_value} - for source_name, target_name in [("org", "org"), ("repo", "repo")]: - source_value = server_info.get(source_name) - if isinstance(source_value, str) and source_value: - token_entry[target_name] = source_value - if remote_name: - token_entry["remote"] = remote_name - return token_entry +def token_server_endpoint(token_record: dict) -> str: + server_record = token_record.get("server", {}) + if not isinstance(server_record, dict): + return "" + return str(server_record.get("endpoint", "")) -def update_token_entry_metadata(token_entry, token_value: str, server_info: dict, remote_name: str) -> tuple[dict, bool]: - if isinstance(token_entry, dict): - updated_entry = dict(token_entry) - else: - updated_entry = {"value": token_value} +def find_token_record(token_data: dict, endpoint: str, token_id: str) -> dict | None: + for token_record in token_data.get("tokens", []): + if token_server_endpoint(token_record) == endpoint and token_record.get("token_id") == token_id: + return token_record + return None - changed = updated_entry.get("value") != token_value - updated_entry["value"] = token_value - for source_name, target_name in [("org", "org"), ("repo", "repo")]: - source_value = server_info.get(source_name) - if isinstance(source_value, str) and source_value and updated_entry.get(target_name) != source_value: - updated_entry[target_name] = source_value - changed = True - if remote_name and updated_entry.get("remote") != remote_name: - updated_entry["remote"] = remote_name + +def find_orphan_token_by_value(token_data: dict, endpoint: str, token_value: str) -> dict | None: + for token_record in token_data.get("tokens", []): + if token_server_endpoint(token_record) != endpoint: + continue + if token_record.get("value") != token_value: + continue + if token_record.get("remotes"): + continue + return token_record + return None + + +def find_remote_record(token_record: dict, remote_name: str) -> dict | None: + for remote_record in token_record.setdefault("remotes", []): + if remote_record.get("name") == remote_name: + return remote_record + return None + + +def upsert_remote_record(token_record: dict, remote_name: str, server_info: dict) -> tuple[dict, bool]: + remote_record = find_remote_record(token_record, remote_name) + changed = False + if remote_record is None: + remote_record = {"name": remote_name, "org": "", "repo": ""} + token_record.setdefault("remotes", []).append(remote_record) changed = True - return updated_entry, changed + + for source_name, target_name in [("org", "org"), ("repo", "repo")]: + source_value = server_info.get(source_name) + if isinstance(source_value, str) and source_value and remote_record.get(target_name) != source_value: + remote_record[target_name] = source_value + changed = True + return remote_record, changed -def register_token(token_data: dict, server_info: dict, remote_name: str, user_name: str, token_value: str) -> str: - server_entry = ensure_server_entry(token_data, server_info) - users = server_entry.setdefault("users", {}) - user_entry = users.setdefault(user_name, {"tokens": {}}) - tokens = user_entry.setdefault("tokens", {}) +def apply_authz_to_token(token_record: dict, remote_record: dict, authz: dict) -> bool: + changed = False + for source_name, target_name in [ + ("user", "user"), + ("valid", "valid"), + ("scope_mask", "scope"), + ]: + source_value = authz.get(source_name) + if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value: + token_record[target_name] = source_value + changed = True - for token_name, token_entry in list(tokens.items()): - if token_entry_field(token_entry, "remote") != remote_name: - continue - updated_entry, changed = update_token_entry_metadata(token_entry, token_value, server_info, remote_name) - tokens[token_name] = updated_entry - return "updated" if changed else "existing" + for source_name, target_name in [ + ("org_mask", "org_perm"), + ("repo_mask", "repo_perm"), + ]: + source_value = authz.get(source_name) + if isinstance(source_value, str) and source_value and remote_record.get(target_name) != source_value: + remote_record[target_name] = source_value + changed = True + return changed - for token_name, token_entry in list(tokens.items()): - if token_entry_value(token_entry) != token_value: - continue - updated_entry, changed = update_token_entry_metadata(token_entry, token_value, server_info, remote_name) - tokens[token_name] = updated_entry - return "updated" if changed else "existing" - tokens[next_token_name(tokens)] = token_entry_with_metadata(token_value, server_info, remote_name) - return "added" +def register_token(token_data: dict, server_info: dict, remote_name: str, token_id: str, token_value: str) -> str: + endpoint = server_info["endpoint"] + token_record = find_token_record(token_data, endpoint, token_id) + if token_record is None: + token_record = find_orphan_token_by_value(token_data, endpoint, token_value) + if token_record is None: + token_record = { + "token_id": token_id, + "value": token_value, + "server": server_record_from_info(server_info), + "remotes": [], + } + token_data.setdefault("tokens", []).append(token_record) + result = "added" + else: + token_record["token_id"] = token_id + result = "updated" + else: + result = "existing" + if token_record.get("value") != token_value: + token_record["value"] = token_value + result = "updated" + if token_record.get("server") != server_record_from_info(server_info): + token_record["server"] = server_record_from_info(server_info) + result = "updated" + + remote_record, remote_changed = upsert_remote_record(token_record, remote_name, server_info) + if remote_changed and result == "existing": + result = "updated" + + authz = load_gitea_authz( + endpoint, + token_id, + token_value, + token_record.get("expires_at", ""), + remote_record.get("org", ""), + remote_record.get("repo", ""), + ) + if apply_authz_to_token(token_record, remote_record, authz) and result == "existing": + result = "updated" + return result def remote_urls(repo_path: Path) -> list[str]: @@ -593,7 +768,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: "org": "", "repo": "", "url_kind": "unsupported", - "user": "", + "token_id": "", "token_value": "", "result": "ignored", } @@ -612,7 +787,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: "org": server_info["org"], "repo": server_info["repo"], "url_kind": "plain", - "user": "", + "token_id": "", "token_value": "", "result": "no_credentials", } @@ -620,7 +795,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: continue auth_urls += 1 - user_name, token_value = credentials + token_id, token_value = credentials row_index = len(remote_rows) remote_rows.append( { @@ -630,12 +805,12 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: "org": server_info["org"], "repo": server_info["repo"], "url_kind": "auth", - "user": user_name, + "token_id": token_id, "token_value": token_value, "result": "found", } ) - found_credentials.append((row_index, server_info, remote_name, user_name, token_value)) + found_credentials.append((row_index, server_info, remote_name, token_id, token_value)) return { "repo_root": repo_root, @@ -664,8 +839,8 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: token_data = load_token_store(config) added = 0 updated = 0 - for row_index, server_info, remote_name, user_name, token_value in found_credentials: - result = register_token(token_data, server_info, remote_name, user_name, token_value) + for row_index, server_info, remote_name, token_id, token_value in found_credentials: + result = register_token(token_data, server_info, remote_name, token_id, token_value) if result == "added": added += 1 elif result == "updated": @@ -683,7 +858,7 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: def count_server_tokens(server_entry: dict) -> int: - return sum(len(user_entry.get("tokens", {})) for user_entry in server_entry.get("users", {}).values()) + return len(server_entry.get("tokens", [])) def mask_secret(secret_value: str) -> str: @@ -692,20 +867,74 @@ def mask_secret(secret_value: str) -> str: return f"{secret_value[:2]}{'*' * (len(secret_value) - 4)}{secret_value[-2:]}" -def token_store_servers(token_data: dict) -> list[tuple[str, dict]]: - servers = token_data.get("servers", {}) - return sorted(servers.items()) - - -def token_pairs_from_users(users_data: dict) -> set[tuple[str, str, str]]: - token_pairs: set[tuple[str, str, str]] = set() - for user_name, user_entry in users_data.items(): - if not isinstance(user_entry, dict): +def store_servers_from_tokens(token_data: dict) -> dict[str, dict]: + servers: dict[str, dict] = {} + for token_record in token_data.get("tokens", []): + server_record = token_record.get("server", {}) + if not isinstance(server_record, dict): continue - for token_entry in user_entry.get("tokens", {}).values(): - token_value = token_entry_value(token_entry) - if token_value: - token_pairs.add((token_entry_field(token_entry, "remote"), str(user_name), token_value)) + endpoint = str(server_record.get("endpoint", "")) + if not endpoint: + continue + + server_entry = servers.setdefault( + endpoint, + { + "type": server_record.get("type", "unknown"), + "scheme": server_record.get("scheme", ""), + "host": server_record.get("host", ""), + "port": server_record.get("port"), + "tokens": [], + }, + ) + for field_name in ["type", "scheme", "host", "port"]: + if server_entry.get(field_name) in {"", None, "unknown"} and server_record.get(field_name) not in {"", None}: + server_entry[field_name] = server_record.get(field_name) + + remotes = token_record.get("remotes", []) + if not isinstance(remotes, list) or not remotes: + remotes = [{}] + for remote_record in remotes: + if not isinstance(remote_record, dict): + continue + server_entry["tokens"].append( + { + "endpoint": endpoint, + "token_id": str(token_record.get("token_id", "")), + "token_value": str(token_record.get("value", "")), + "user": str(token_record.get("user", "")), + "valid": str(token_record.get("valid", "")), + "scope_mask": str(token_record.get("scope", "")), + "expires_at": str(token_record.get("expires_at", "")), + "remote": str(remote_record.get("name", "")), + "owner": str(remote_record.get("org", "")), + "repo_name": str(remote_record.get("repo", "")), + "org_mask": str(remote_record.get("org_perm", "")), + "repo_mask": str(remote_record.get("repo_perm", "")), + } + ) + return servers + + +def token_store_servers(token_data: dict) -> list[tuple[str, dict]]: + return sorted(store_servers_from_tokens(token_data).items()) + + +def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str, str]]: + token_pairs: set[tuple[str, str, str, str, str]] = set() + for row in server_entry.get("tokens", []): + token_id = row.get("token_id", "") + token_value = row.get("token_value", "") + if token_id and token_value: + token_pairs.add( + ( + str(row.get("remote", "")), + str(token_id), + str(token_value), + str(row.get("org", row.get("owner", ""))), + str(row.get("repo", row.get("repo_name", ""))), + ) + ) return token_pairs @@ -736,7 +965,6 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup "urls": 0, "auth_urls": 0, "plain_urls": 0, - "users": {}, "tokens": [], "plain_remotes": [], }, @@ -760,20 +988,16 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup continue entry["auth_urls"] += 1 - user_name, token_value = credentials + token_id, token_value = credentials entry["tokens"].append( { "remote": remote_name, "org": server_info["org"], "repo": server_info["repo"], - "user": user_name, + "token_id": token_id, "token_value": token_value, } ) - user_entry = entry["users"].setdefault(user_name, {"tokens": {}}) - tokens = user_entry.setdefault("tokens", {}) - if not token_value_exists(tokens, token_value): - tokens[next_token_name(tokens)] = token_entry_with_metadata(token_value, server_info, remote_name) return repo_root, repo_servers @@ -786,8 +1010,8 @@ def compare_server_entries(repo_entry: dict | None, store_entry: dict | None) -> if repo_entry is None and store_entry is not None: return "store_only", 0, count_server_tokens(store_entry) - repo_pairs = token_pairs_from_users(repo_entry.get("users", {})) - store_pairs = token_pairs_from_users(store_entry.get("users", {})) + repo_pairs = token_pairs_from_entry(repo_entry) + store_pairs = token_pairs_from_entry(store_entry) repo_only_pairs = repo_pairs - store_pairs store_only_pairs = store_pairs - repo_pairs if not repo_only_pairs and not store_only_pairs: @@ -799,21 +1023,6 @@ def compare_server_entries(repo_entry: dict | None, store_entry: dict | None) -> return "diverged", len(repo_only_pairs), len(store_only_pairs) -def token_name_rows(users_data: dict) -> list[tuple[str, str]]: - rows: list[tuple[str, str]] = [] - for user_name in sorted(users_data): - user_entry = users_data[user_name] - if not isinstance(user_entry, dict): - continue - tokens = user_entry.get("tokens", {}) - if not tokens: - rows.append((str(user_name), "")) - continue - for token_name in sorted(tokens): - rows.append((str(user_name), str(token_name))) - return rows - - def repo_result_for_entry(repo_entry: dict | None) -> tuple[str, str]: if repo_entry is None: return "missing", "missing" @@ -935,26 +1144,26 @@ def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry: 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_entry in sorted(user_entry.get("tokens", {}).items()): - token_value = token_entry_value(token_entry) - if token_value: - rows.append( - { - "endpoint": endpoint, - "user": str(user_name), - "token_name": str(token_name), - "remote": token_entry_field(token_entry, "remote"), - "owner": token_entry_field(token_entry, "org"), - "repo_name": token_entry_field(token_entry, "repo"), - "token_value": token_value, - "expires_at": token_entry_expires_at(token_entry), - } - ) + for row in server_entry.get("tokens", []): + token_value = row.get("token_value", "") + token_id = row.get("token_id", "") + if token_value and token_id: + rows.append( + { + "endpoint": endpoint, + "user": str(row.get("user", "")), + "token_id": str(token_id), + "remote": str(row.get("remote", "")), + "owner": str(row.get("owner", "")), + "repo_name": str(row.get("repo_name", "")), + "token_value": str(token_value), + "expires_at": str(row.get("expires_at", "")), + "valid": str(row.get("valid", "")), + "scope_mask": str(row.get("scope_mask", "")), + "org_mask": str(row.get("org_mask", "")), + "repo_mask": str(row.get("repo_mask", "")), + } + ) return rows @@ -970,7 +1179,7 @@ def repo_token_rows( "remote": row["remote"], "owner": row.get("org", ""), "repo_name": row.get("repo", ""), - "user": row.get("user", ""), + "token_id": row.get("token_id", ""), "token_value": row.get("token_value", ""), } for row in scan_rows @@ -989,7 +1198,7 @@ def repo_token_rows( "remote": row.get("remote", ""), "owner": row.get("org", ""), "repo_name": row.get("repo", ""), - "user": row.get("user", ""), + "token_id": row.get("token_id", ""), "token_value": row.get("token_value", ""), } ) @@ -1077,8 +1286,7 @@ def repo_permission_mask(repo_permissions: dict) -> str: def load_gitea_authz( endpoint: str, - user_name: str, - token_name: str, + token_id: str, token_value: str, expires_at: str, owner: str, @@ -1091,33 +1299,42 @@ def load_gitea_authz( "scope_mask": "!!!!!", "org_mask": "!!!!!", "repo_mask": "!!!!", + "user": "", } 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) + user_info = api_get_json(f"{api_base}/user", token_value) + user_name = str(user_info.get("login", "")) if isinstance(user_info, dict) else "" + result["user"] = user_name result["valid"] = valid_label(True, expires_at) - 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: + if user_name: + 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]) + except (OSError, ValueError, urlerror.URLError): + result["scope_mask"] = "?????" + + if user_name and 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: + if user_name and owner and repo_name: repo_url = ( f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}" f"/collaborators/{quote(user_name, safe='')}/permission" @@ -1161,22 +1378,25 @@ def print_token_item_rows( for endpoint in endpoint_names } project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows) - authz_cache: dict[tuple[str, str, str, str, str, str], dict] = {} - used_store_keys: set[tuple[str, str, str, str]] = set() + used_store_keys: set[tuple[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_id: str, token_value: str, marker: str, owner: str, repo_name: str, - authz: dict | None = None, + store_row: dict | None = None, ) -> None: endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None)) + valid_value = store_row.get("valid", "?") if store_row else "?" + scope_value = store_row.get("scope_mask", "?????") if store_row else "?????" + org_value = store_row.get("org_mask", "?????") if store_row else "?????" + repo_value = store_row.get("repo_mask", "????") if store_row else "????" token_rows.append( { "item": str(len(token_rows) + 1), @@ -1187,12 +1407,12 @@ def print_token_item_rows( "repo_name": repo_name, "user": user_name, "remote": remote_name, - "token_ref": format_token_ref(token_name, marker), + "token_ref": format_token_ref(token_id, marker), "token": short_secret(token_value), - "valid": authz.get("valid", "?") if authz else "?", - "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 "????", + "valid": valid_value or "?", + "scope_mask": scope_value or "?????", + "org_mask": org_value or "?????", + "repo_mask": repo_value or "????", } ) @@ -1200,41 +1420,35 @@ def print_token_item_rows( endpoint = row["endpoint"] token_value = row["token_value"] store_row = store_by_remote.get((endpoint, row["remote"])) - token_name = store_row["token_name"] if store_row else "" + token_id = row["token_id"] expires_at = store_row.get("expires_at", "") if store_row else "" marker = "R" - authz = None + synced_store_row = None if store_row: - used_store_keys.add((endpoint, store_row["user"], store_row["token_name"], store_row["token_value"])) - if store_row["user"] == row["user"] and store_row["token_value"] == token_value: - authz_key = (endpoint, row["user"], token_value, expires_at, 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, - expires_at, - row["owner"], - row["repo_name"], - ) - authz_cache[authz_key] = authz - marker = "*" if authz.get("ok") else "!" + repo_fields_match = ( + store_row["token_id"] == row["token_id"] + and store_row["token_value"] == token_value + and store_row["owner"] == row["owner"] + and store_row["repo_name"] == row["repo_name"] + ) + if repo_fields_match: + used_store_keys.add((endpoint, store_row["token_id"], store_row["remote"])) + synced_store_row = store_row + marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*" append_token_row( endpoint, - row["user"], + synced_store_row.get("user", "") if synced_store_row else "", row["remote"], - token_name, + token_id, token_value, marker, row["owner"], row["repo_name"], - authz if marker == "*" else None, + synced_store_row, ) for row in store_rows: - store_key = (row["endpoint"], row["user"], row["token_name"], row["token_value"]) + store_key = (row["endpoint"], row["token_id"], row["remote"]) if store_key in used_store_keys: continue fallback = project_fallback.get(row["endpoint"], {}) @@ -1242,7 +1456,7 @@ def print_token_item_rows( row["endpoint"], row["user"], row["remote"], - row["token_name"], + row["token_id"], row["token_value"], "S", row["owner"] or fallback.get("owner", ""), @@ -1253,59 +1467,34 @@ def print_token_item_rows( return status_counts -def resolve_server_from_store(token_data: dict, endpoint: str | None) -> tuple[str, dict]: - servers = token_data.get("servers", {}) - if endpoint: - server_entry = servers.get(endpoint) - if server_entry is None: - raise SystemExit(f"Missing server endpoint in token store: {endpoint}") - return endpoint, server_entry +def resolve_token_from_store( + token_data: dict, + endpoint: str | None, + token_id: str | None, + user_name: str | None, +) -> dict: + candidates = [] + for token_record in token_data.get("tokens", []): + token_endpoint = token_server_endpoint(token_record) + if endpoint and token_endpoint != endpoint: + continue + if token_id and token_record.get("token_id") != token_id: + continue + if user_name and token_record.get("user") != user_name: + continue + candidates.append(token_record) - if not servers: - raise SystemExit("Token store is empty.") - if len(servers) > 1: - available = ", ".join(sorted(servers)) - raise SystemExit(f"Multiple servers in token store. Pass --server. Available: {available}") - - endpoint_value = next(iter(sorted(servers))) - return endpoint_value, servers[endpoint_value] - - -def resolve_user_from_server(server_entry: dict, user_name: str | None) -> tuple[str, dict]: - users = server_entry.get("users", {}) - if user_name: - user_entry = users.get(user_name) - if user_entry is None: - raise SystemExit(f"Missing user in token store: {user_name}") - return user_name, user_entry - - if not users: - raise SystemExit("No users found in the selected server entry.") - if len(users) > 1: - available = ", ".join(sorted(users)) - raise SystemExit(f"Multiple users in selected server entry. Pass --user. Available: {available}") - - user_value = next(iter(sorted(users))) - return user_value, users[user_value] - - -def resolve_token_from_user(user_entry: dict, token_name: str | None) -> tuple[str, str]: - tokens = user_entry.get("tokens", {}) - if token_name: - token_entry = tokens.get(token_name) - token_value = token_entry_value(token_entry) - if not token_value: - raise SystemExit(f"Missing token name in token store: {token_name}") - return token_name, token_value - - if not tokens: - raise SystemExit("No tokens found for the selected user.") - if len(tokens) > 1: - available = ", ".join(sorted(tokens)) - raise SystemExit(f"Multiple tokens for the selected user. Pass --token-name. Available: {available}") - - token_key = next(iter(sorted(tokens))) - return token_key, token_entry_value(tokens[token_key]) + if not candidates: + raise SystemExit("No matching token in tokens.json.") + if len(candidates) > 1: + available = ", ".join( + sorted( + f"{token_server_endpoint(token_record)}:{token_record.get('token_id', '')}" + for token_record in candidates + ) + ) + raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name. Available: {available}") + return candidates[0] def sanitize_remote_url(remote_url: str) -> tuple[str, bool]: @@ -1344,7 +1533,7 @@ def resolve_repo_argument(repo_arg: str | None) -> Path: def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_repo_argument(args.repo) token_data = load_token_store(config, write_normalized=False) - store_servers = token_data.get("servers", {}) + store_servers = store_servers_from_tokens(token_data) report = scan_repo_remotes(config, repo_path) repo_root, repo_servers = collect_repo_server_entries(config, repo_path) endpoint_names = sorted(set(repo_servers) | set(store_servers)) @@ -1355,7 +1544,7 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: token_data = load_token_store(config, write_normalized=False) servers = token_store_servers(token_data) if args.server: - server_entry = token_data.get("servers", {}).get(args.server) + server_entry = dict(store_servers_from_tokens(token_data)).get(args.server) if server_entry is None: raise SystemExit(f"Missing server endpoint in token store: {args.server}") servers = [(args.server, server_entry)] @@ -1374,29 +1563,31 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: print(f"scheme\t{server_entry.get('scheme', '')}") print(f"host\t{server_entry.get('host', '')}") print(f"port\t{server_entry.get('port', '')}") - print(f"users\t{len(server_entry.get('users', {}))}") print(f"tokens\t{count_server_tokens(server_entry)}") - for user_name in sorted(server_entry.get("users", {})): - user_entry = server_entry["users"][user_name] - print(f"user\t{user_name}") - for token_name in sorted(user_entry.get("tokens", {})): - token_entry = user_entry["tokens"][token_name] - token_value = token_entry_value(token_entry) - secret_value = token_value if args.show_secrets else mask_secret(token_value) - print(f"token\t{token_name}\t{secret_value}") - for field_name in ["remote", "org", "repo"]: - field_value = token_entry_field(token_entry, field_name) - if field_value: - print(f"{field_name}\t{token_name}\t{field_value}") - expires_at = token_entry_expires_at(token_entry) - if expires_at: - print(f"expires_at\t{token_name}\t{expires_at}") + for row in sorted(server_entry.get("tokens", []), key=lambda item: (item.get("token_id", ""), item.get("remote", ""))): + token_value = row.get("token_value", "") + secret_value = token_value if args.show_secrets else mask_secret(token_value) + print(f"token\t{row.get('token_id', '')}\t{secret_value}") + for field_name in ["user", "valid", "scope_mask", "expires_at"]: + field_value = row.get(field_name, "") + if field_value: + print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}") + if row.get("remote"): + print( + "remote\t" + f"{row.get('token_id', '')}\t{row.get('remote', '')}\t" + f"{row.get('owner', '')}\t{row.get('repo_name', '')}" + ) + for field_name in ["org_mask", "repo_mask"]: + field_value = row.get(field_name, "") + if field_value: + print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}") def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_root, repo_servers = collect_repo_server_entries(config, resolve_repo_argument(args.repo)) token_data = load_token_store(config, write_normalized=False) - store_servers = token_data.get("servers", {}) + store_servers = store_servers_from_tokens(token_data) endpoint_names = sorted(set(repo_servers) | set(store_servers)) if args.server: if args.server not in set(endpoint_names): @@ -1429,14 +1620,20 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: if target_server_info is None: raise SystemExit(f"Only http/https remote URLs are supported: {target_url}") - server_endpoint, server_entry = resolve_server_from_store(token_data, args.server or target_server_info["endpoint"]) - user_name, user_entry = resolve_user_from_server(server_entry, args.user) - token_name, token_value = resolve_token_from_user(user_entry, args.token_name) + token_record = resolve_token_from_store( + token_data, + args.server or target_server_info["endpoint"], + args.token_name, + args.user, + ) + server_endpoint = token_server_endpoint(token_record) + token_id = str(token_record.get("token_id", "")) + token_value = str(token_record.get("value", "")) - final_url = credentialed_remote_url(target_url, user_name, token_value) + final_url = credentialed_remote_url(target_url, token_id, token_value) status = "added" if existing_remote_url is None else "updated" existing_credentials = remote_credentials(existing_remote_url) if existing_remote_url else None - if existing_credentials == (user_name, token_value): + if existing_credentials == (token_id, token_value): status = "unchanged" elif existing_credentials is not None and not args.replace: raise SystemExit( @@ -1447,8 +1644,8 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: print(f"repo_root\t{repo_root}") print(f"remote\t{args.remote}") print(f"server\t{server_endpoint}") - print(f"user\t{user_name}") - print(f"token_name\t{token_name}") + print(f"user\t{token_record.get('user', '')}") + print(f"token_name\t{token_id}") print(f"status\t{status}") print(f"url\t{sanitize_remote_url(final_url)[0]}") @@ -1467,7 +1664,7 @@ def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None report = sync_tokens_from_repo(config, repo_path) repo_root, repo_servers = collect_repo_server_entries(config, repo_path) token_data = load_token_store(config, write_normalized=False) - store_servers = token_data.get("servers", {}) + store_servers = store_servers_from_tokens(token_data) endpoint_names = sorted(set(repo_servers) | set(store_servers)) print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) return @@ -1696,8 +1893,8 @@ def add_repo_option(parser: argparse.ArgumentParser) -> None: def add_store_selection_options(parser: argparse.ArgumentParser) -> None: parser.add_argument("--server", help="Server endpoint from tokens.json.") - parser.add_argument("--user", help="User name inside the selected server entry.") - parser.add_argument("--token-name", help="Token name inside the selected user entry, for example 't1'.") + parser.add_argument("--user", help="API user recorded on the token.") + parser.add_argument("--token-name", help="Token id inside tokens.json, for example 't1'.") def print_table(headers: list[str], rows: list[list[str]]) -> None: @@ -1776,6 +1973,7 @@ def print_main_overview(config_path: Path) -> None: print("Docs:") print(" doc/rvctl.md") print(" doc/tokens.md") + print(" doc/tokens.schema.json") print(" doc/workspace.md") @@ -1791,7 +1989,7 @@ def print_tokens_overview() -> None: ["command", "common options", "direction", "purpose"], [ ["scan", "[--repo PATH]", "read-only", "print token table with remote/store marker and auth masks"], - ["read", "[--server ENDPOINT]", "tokens.json", "show servers, users and token names"], + ["read", "[--server ENDPOINT]", "tokens.json", "show servers, token ids and remotes"], ["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"], ["update", "--from remotes|store", "selected", "copy token data in an explicit direction"], @@ -1805,7 +2003,7 @@ def print_tokens_overview() -> None: ["scan current repo", "./rvctl tokens scan"], ["scan selected card", "./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03"], ["compare state", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03"], - ["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL --user u1 --token-name t1"], + ["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL --token-name t1"], ], ) @@ -1904,7 +2102,7 @@ def build_parser() -> argparse.ArgumentParser: tokens_read_parser = tokens_subparsers.add_parser( "read", - help="Read tokens.json and print stored servers, users and token names.", + help="Read tokens.json and print stored servers, token ids and remotes.", ) tokens_read_parser.add_argument("--server", help="Filter output to one server endpoint.") tokens_read_parser.add_argument("--show-secrets", action="store_true", help="Print full token values.")