diff --git a/README.md b/README.md index 279d2bc..42a7493 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ Masz dwie drogi. To jest wariant dydaktyczny, jesli uczen ma cwiczyc reczne dodawanie remota z tokenem do konkretnego zdalnego endpointu. Jesli launcher zobaczy URL w formacie -`http://LOGIN:TOKEN@...`, zapisze ten token lokalnie do `tokens/tokens.json`. +`http://LOGIN:TOKEN@...`, mozesz zapisac ten token lokalnie komenda +`tokens sync remote r1`. Przyklad: @@ -115,7 +116,7 @@ Minimalny format: "version": 3, "tokens": [ { - "token_id": "t1", + "id": "r1", "value": "TU_WSTAW_TOKEN", "server": { "type": "gitea", @@ -124,13 +125,9 @@ Minimalny format: "host": "77.90.8.171", "port": 3001 }, - "remotes": [ - { - "name": "r1", - "org": "edu-tools", - "repo": "rv-launcher" - } - ] + "user": "u1", + "org": "edu-tools", + "repo": "rv-launcher" } ] } @@ -159,7 +156,10 @@ Podstawowe komendy tokenow: ```bash ./rvctl tokens scan -./rvctl tokens add t1 +./rvctl tokens sync remote r1 +./rvctl tokens update r1 +./rvctl tokens sync store r1 +./rvctl tokens add r1 ./rvctl tokens read ./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ``` @@ -167,12 +167,12 @@ Podstawowe komendy tokenow: `tokens scan` wypisuje tabele `tokens` i niczego nie zapisuje: jeden wiersz na logiczny remote tokena. `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 `????`. -Zapis z remote do `tokens.json` oraz odczyt `valid` i uprawnien z API robi -dopiero `tokens update --from remotes`. +remote i `tokens.json` sa zgodne, `R` oznacza token tylko w remote, a `S` +token tylko w `tokens.json`. Kolumny `scope`, `org` i `repo` sa maskami +uprawnien; bez zgodnego wpisu maja wartosc `?????????`, `?????` albo `????`. +Zapis z remote do `tokens.json` robi `tokens sync remote r1`. +Odczyt `valid` i uprawnien z API robi `tokens update r1`. +Zapis z `tokens.json` do remota robi `tokens sync store r1`. Kolumna `valid` oznacza, czy token zostal zaakceptowany przez API teraz. Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` moze pokazac date wygasniecia; bez daty poprawny token pokazuje `forever`. diff --git a/doc/rvctl.md b/doc/rvctl.md index dd93a90..63abb57 100644 --- a/doc/rvctl.md +++ b/doc/rvctl.md @@ -103,22 +103,23 @@ Minimalny format pliku: ```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": [ + { + "id": "r1", + "value": "TU_WSTAW_TOKEN", + "server": { + "type": "gitea", + "endpoint": "http://77.90.8.171:3001", + "scheme": "http", + "host": "77.90.8.171", + "port": 3001 + }, + "user": "u1", + "org": "edu-tools", + "repo": "rv-launcher" } - } + ] } ``` @@ -306,13 +307,13 @@ Typowy wynik: tokens item server proto host org repo user remote token_ref token valid scope org repo ---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr-- -1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 t1 * e59cc...13be forever -----w--- +++++ ++++ +1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever -----w--- +++++ ++++ ``` `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`. +po prawej. Nazwa tokena jest taka sama jak nazwa git remote, np. `r1`. Marker +`*` oznacza, ze remote i `tokens.json` sa zgodne. Marker `R` oznacza token tylko +w remote, a `S` token tylko w `tokens.json`. Maski uprawnien: @@ -330,10 +331,11 @@ Przyklad: ./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 ``` -## `tokens add TOKEN_ID` +## `tokens add REMOTE_ID` -Dodaje pusty szkielet tokena do `tokens.json`. Pole `value` jest puste i trzeba -je uzupelnic recznie przed uzyciem tokena. +Dodaje pusty szkielet tokena do `tokens.json`. `REMOTE_ID` musi byc taki sam +jak nazwa git remote, np. `r1`. Pole `value` jest puste i trzeba je uzupelnic +recznie przed uzyciem tokena. Przelaczniki: @@ -342,9 +344,9 @@ Przelaczniki: - `--value TOKEN` Opcjonalna wartosc tokena. Domyslnie pusta. - `--user NAME` - Opcjonalny opis usera API. + Login uzywany w URL-u auth, np. `u1`. - `--remote NAME` - Opcjonalny remote powiazany z tokenem. + Alias zgodnosci. Jesli podany, musi byc taki sam jak `REMOTE_ID`. - `--org NAME` Opcjonalna organizacja dla remota. - `--repo NAME` @@ -355,8 +357,51 @@ Przelaczniki: Przyklady: ```bash -./rvctl tokens add r6 -./rvctl tokens add t1 --remote r1 --org edu-tools --repo rv-launcher +./rvctl tokens add r1 +./rvctl tokens add r1 --user u1 --org edu-tools --repo rv-launcher +``` + +## `tokens sync remote REMOTE_ID` + +Czyta dane auth z git remote `REMOTE_ID` i zapisuje je do `tokens.json`. Nie +pobiera metadanych z API. + +Przelaczniki: + +- `--repo PATH` + Sciezka wewnatrz docelowego repo. Domyslnie biezacy katalog. +- `--dry-run` + Pokazuje plan bez zapisu. + +Przyklad: + +```bash +./rvctl tokens sync remote r1 +./rvctl tokens sync remote r1 --repo ~/dev/workspace/rv/tools/rv-launcher +``` + +## `tokens sync store REMOTE_ID` + +Zapisuje dane auth z rekordu `REMOTE_ID` w `tokens.json` do git remote o tej +samej nazwie. + +Przelaczniki: + +- `--repo PATH` + Sciezka wewnatrz docelowego repo. Domyslnie biezacy katalog. +- `--url URL` + URL remota, jesli remote jeszcze nie istnieje. +- `--server ENDPOINT` + Endpoint serwera z `tokens.json`. +- `--replace` + Nadpisuje inne dane auth juz wpisane w remote URL. +- `--dry-run` + Pokazuje plan bez zapisu. + +Przyklad: + +```bash +./rvctl tokens sync store r1 --repo ~/dev/workspace/rv/series/inf/03 ``` ## `tokens read` @@ -379,10 +424,9 @@ typegitea schemehttp host77.90.8.171 port3001 -users1 tokens1 -useru1 -tokent1SE****23 +idr1SE****23 +userr1u1 ``` Przyklad: @@ -415,7 +459,7 @@ token_path... tokens item server proto host org repo user remote token_ref token valid scope org repo ---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr-- -1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 t1 * e59cc...13be forever -----w--- +++++ ++++ +1 gitea http 77.90.8.171:3001 edu-tools rv-launcher u1 r1 r1 * e59cc...13be forever -----w--- +++++ ++++ status itemvalue @@ -445,7 +489,7 @@ Przelaczniki: - `--user NAME` Uzytkownik z wybranego endpointu. - `--token-name NAME` - Nazwa tokena, na przyklad `t1`. + Remote id w `tokens.json`, na przyklad `r1`. Domyslnie wartosc `--remote`. - `--replace` Nadpisuje inne dane auth juz wpisane w remote URL. - `--dry-run` @@ -454,12 +498,30 @@ Przelaczniki: Przyklad: ```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 +./rvctl tokens write --repo ~/dev/workspace/rv/series/inf/03 --remote r1 --server http://77.90.8.171:3001 ``` -## `tokens update` +## `tokens update REMOTE_ID` -Uruchamia synchronizacje w zadanym kierunku. +Pobiera z API metadane dla rekordu `REMOTE_ID` zapisanego w `tokens.json`. +Nie synchronizuje sekretu z git remote. + +Przelaczniki: + +- `--server ENDPOINT` + Opcjonalny wybor endpointu, jesli ten sam `REMOTE_ID` istnieje dla wielu serwerow. +- `--dry-run` + Pokazuje plan bez zapisu. + +Przyklad: + +```bash +./rvctl tokens update r1 +``` + +## `tokens update --from ...` + +Komendy zgodnosci dla starego modelu kierunkowego. Przelaczniki: @@ -478,7 +540,7 @@ Przelaczniki: - `--user NAME` Opcjonalny wybor usera dla `--from store`. - `--token-name NAME` - Opcjonalny wybor tokena dla `--from store`. + Opcjonalny wybor remote id dla `--from store`. - `--replace` Nadpisuje inne auth przy `--from store`. - `--dry-run` @@ -488,7 +550,7 @@ Przyklady: ```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 ~/dev/workspace/rv/series/inf/03 --remote r1 --server http://77.90.8.171:3001 ``` ## `submission [series] [card]` diff --git a/doc/tokens.md b/doc/tokens.md index 3d0a1b2..ce65c21 100644 --- a/doc/tokens.md +++ b/doc/tokens.md @@ -4,28 +4,31 @@ Plik opisuje model pracy z tokenami w launcherze. ## Zrodlo prawdy -Sa dwa miejsca, w ktorych moga byc zapisane tokeny: +Sa dwa miejsca, w ktorych moga byc zapisane dane dostepowe: -- remote URL-e w repo, na przyklad `http://t1:SECRET@host/org/repo.git` +- remote URL w repo, na przyklad `http://u1:SECRET@host/org/repo.git` - lokalny plik `~/dev/workspace/rv/tokens/tokens.json` -`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. +`tokens scan` tylko porownuje oba miejsca i niczego nie zapisuje. +`tokens sync remote r1` czyta remote `r1` i zapisuje jego dane w +`tokens.json`. +`tokens sync store r1` zapisuje rekord `r1` z `tokens.json` do git remote +`r1`. +`tokens update r1` nie synchronizuje sekretu; tylko pobiera z API metadane +tokenu: `valid`, `scope`, `org_perm` i `repo_perm`. ## Format `tokens.json` -Aktualny format to `version: 3`. Glownym rekordem jest token. `server`, `user`, -`valid`, `scope` i `remotes` sa atrybutami tego tokena. +Aktualny format to `version: 3`. Glownym rekordem jest remote-token. Pole +`id` jest obowiazkowe i musi byc takie samo jak nazwa git remote, np. `r1` +albo `r1a`. ```json { "version": 3, "tokens": [ { - "token_id": "t1", + "id": "r1", "value": "SECRET", "server": { "type": "gitea", @@ -35,17 +38,33 @@ Aktualny format to `version: 3`. Glownym rekordem jest token. `server`, `user`, "port": 3001 }, "user": "u1", + "org": "edu-tools", + "repo": "rv-launcher", "valid": "forever", - "scope": "-----w---", - "remotes": [ - { - "name": "r1", - "org": "edu-tools", - "repo": "rv-launcher", - "org_perm": "+++++", - "repo_perm": "++++" - } - ] + "scope": { + "a": "-", + "A": "-", + "i": "-", + "m": "-", + "n": "-", + "o": "-", + "p": "-", + "r": "w", + "u": "-" + }, + "org_perm": { + "o": "+", + "a": "+", + "w": "+", + "r": "+", + "c": "+" + }, + "repo_perm": { + "o": "+", + "a": "+", + "w": "+", + "r": "+" + } } ] } @@ -57,28 +76,27 @@ Schemat JSON jest w pliku `doc/tokens.schema.json`. Pola synchronizowane z remote URL-a: +- `id` - nazwa git remote, np. `r1`; to jest klucz rekordu w `tokens.json` - `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@...` +- `user` - login z URL-a, czyli lewa strona `http://u1:SECRET@...` - `value` - sekret tokena -- `remotes[].name` - nazwa remota, na przyklad `r1` -- `remotes[].org` - organizacja z URL-a -- `remotes[].repo` - repo z URL-a +- `org` - organizacja z URL-a +- `repo` - repo z URL-a -Pola wzbogacane przez API przy `tokens update --from remotes`: +Pola wzbogacane przez API przy `tokens update r1`: -- `user` - login wlasciciela tokena odczytany z API - `valid` - `forever`, data `expires_at`, `invalid`, `?` albo `!` -- `scope` - maska scope tokena w kolejnosci `aAimnopru` -- `remotes[].org_perm` - maska praw w organizacji -- `remotes[].repo_perm` - maska praw w repo +- `scope` - mapa scope tokena +- `org_perm` - mapa praw w organizacji +- `repo_perm` - mapa praw w repo ## Porownanie Porownanie z `git remote -v` jest robione po: ```text -server.endpoint + remote.name + token_id + value + org + repo +server.endpoint + id + user + value + org + repo ``` Znacznik w kolumnie `token_ref`: @@ -88,10 +106,13 @@ Znacznik w kolumnie `token_ref`: - `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 +Bez zgodnego wpisu kolumny `valid`, `scope`, `org` i `repo` w raporcie maja wartosc `?`, bo launcher nie pokazuje metadanych API dla niesparowanych wpisow. -## Maski uprawnien +## Uprawnienia + +W `tokens.json` uprawnienia sa zapisane jako mapy klucz-wartosc. W tabeli CLI sa +pokazywane skrocone maski. Naglowki masek: @@ -119,7 +140,22 @@ Znaki w `scope`: - `?` - nie wczytano - `!` - blad wczytania -Znaki w `org` i `repo`: +Kategorie `org_perm`: + +- `o` - owner +- `a` - admin +- `w` - write +- `r` - read +- `c` - create repository + +Kategorie `repo_perm`: + +- `o` - owner +- `a` - admin +- `w` - write +- `r` - read + +Znaki w `org_perm` i `repo_perm`: - `+` - flaga wlaczona - `-` - flaga wylaczona @@ -130,26 +166,43 @@ Znaki w `org` i `repo`: ### `tokens scan` -Read-only. Czyta remote URL-e i `tokens.json`, a potem wypisuje tabele `tokens`. -Nie tworzy i nie modyfikuje `tokens.json`. +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` +### `tokens sync remote REMOTE_ID` -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`. +Kopiuje dane dostepowe z git remote do `tokens.json`. Nie pobiera metadanych z +API. ```bash -./rvctl tokens update --from remotes --repo ~/dev/workspace/rv/tools/rv-launcher -./rvctl tokens update --from remotes --dry-run +./rvctl tokens sync remote r1 +./rvctl tokens sync remote r1 --repo ~/dev/workspace/rv/tools/rv-launcher ``` -`--dry-run` dziala jak read-only raport i niczego nie zapisuje. +### `tokens update REMOTE_ID` + +Pobiera z API metadane dla rekordu `REMOTE_ID` juz zapisanego w `tokens.json`. +Nie zmienia git remote URL-a i nie kopiuje sekretu z remote. + +```bash +./rvctl tokens update r1 +./rvctl tokens update r1 --dry-run +``` + +### `tokens sync store REMOTE_ID` + +Zapisuje dane z rekordu `REMOTE_ID` w `tokens.json` do git remote o tej samej +nazwie. + +```bash +./rvctl tokens sync store r1 --repo ~/dev/workspace/rv/series/inf/03 +./rvctl tokens sync store r1 --repo PATH --replace +``` ### `tokens read` @@ -163,16 +216,16 @@ Pokazuje zawartosc `tokens.json`. ### `tokens add` -Dodaje pusty szkielet tokena do `tokens.json`. Pole `value` zostaje puste, zeby -mozna bylo recznie wkleic sekret. +Dodaje pusty szkielet rekordu do `tokens.json`. `REMOTE_ID` musi odpowiadac +nazwie git remote. ```bash -./rvctl tokens add t1 -./rvctl tokens add r6 --server http://77.90.8.171:3001 -./rvctl tokens add t1 --remote r1 --org edu-tools --repo rv-launcher +./rvctl tokens add r1 +./rvctl tokens add r1 --server http://77.90.8.171:3001 --user u1 --org edu-tools --repo rv-launcher ``` -`tokens write` nie uzyje pustego tokena. Najpierw trzeba uzupelnic `value`. +`tokens sync store r1` nie uzyje pustego tokena. Najpierw trzeba uzupelnic +`value` i `user`. ### `tokens stats` @@ -182,23 +235,12 @@ Pokazuje kontekst, tabele `tokens` i podsumowanie statusow endpointow. ./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ``` -### `tokens write` +### Komendy zgodnosci -Zapisuje wybrany token z `tokens.json` do remote URL-a repo. +Stare komendy nadal dzialaja, ale sa mniej jednoznaczne: ```bash -./rvctl tokens write \ - --repo ~/dev/workspace/rv/series/inf/03 \ - --remote r1 \ - --server http://77.90.8.171:3001 \ - --token-name t1 -``` - -### `tokens update --from store` - -Alias kierunkowy na zapis store -> remote. Uzywa tych samych opcji co -`tokens write`. - -```bash -./rvctl tokens update --from store --repo PATH --remote r1 --server http://77.90.8.171:3001 --token-name t1 +./rvctl tokens update --from remotes --repo PATH +./rvctl tokens update --from store --repo PATH --remote r1 --server http://77.90.8.171:3001 +./rvctl tokens write --repo PATH --remote r1 --server http://77.90.8.171:3001 ``` diff --git a/doc/tokens.schema.json b/doc/tokens.schema.json index 6245d48..b93eafe 100644 --- a/doc/tokens.schema.json +++ b/doc/tokens.schema.json @@ -42,14 +42,125 @@ } } }, - "remote": { + "scope_permissions": { "type": "object", "additionalProperties": false, - "required": ["name", "org", "repo"], + "required": ["a", "A", "i", "m", "n", "o", "p", "r", "u"], "properties": { - "name": { + "a": { + "$ref": "#/$defs/scope_value", + "description": "activitypub" + }, + "A": { + "$ref": "#/$defs/scope_value", + "description": "admin" + }, + "i": { + "$ref": "#/$defs/scope_value", + "description": "issue" + }, + "m": { + "$ref": "#/$defs/scope_value", + "description": "misc" + }, + "n": { + "$ref": "#/$defs/scope_value", + "description": "notification" + }, + "o": { + "$ref": "#/$defs/scope_value", + "description": "organization" + }, + "p": { + "$ref": "#/$defs/scope_value", + "description": "package" + }, + "r": { + "$ref": "#/$defs/scope_value", + "description": "repository" + }, + "u": { + "$ref": "#/$defs/scope_value", + "description": "user" + } + } + }, + "org_permissions": { + "type": "object", + "additionalProperties": false, + "required": ["o", "a", "w", "r", "c"], + "properties": { + "o": { + "$ref": "#/$defs/flag_value", + "description": "owner" + }, + "a": { + "$ref": "#/$defs/flag_value", + "description": "admin" + }, + "w": { + "$ref": "#/$defs/flag_value", + "description": "write" + }, + "r": { + "$ref": "#/$defs/flag_value", + "description": "read" + }, + "c": { + "$ref": "#/$defs/flag_value", + "description": "create repository" + } + } + }, + "repo_permissions": { + "type": "object", + "additionalProperties": false, + "required": ["o", "a", "w", "r"], + "properties": { + "o": { + "$ref": "#/$defs/flag_value", + "description": "owner" + }, + "a": { + "$ref": "#/$defs/flag_value", + "description": "admin" + }, + "w": { + "$ref": "#/$defs/flag_value", + "description": "write" + }, + "r": { + "$ref": "#/$defs/flag_value", + "description": "read" + } + } + }, + "scope_value": { + "enum": ["w", "r", "-", "?", "!"] + }, + "flag_value": { + "enum": ["+", "-", "?", "!"] + }, + "token": { + "type": "object", + "additionalProperties": false, + "required": ["id", "value", "server", "org", "repo"], + "properties": { + "id": { "type": "string", - "minLength": 1 + "minLength": 1, + "description": "Git remote name, for example r1 or r1a." + }, + "value": { + "type": "string", + "description": "Token secret. May be empty only in a manual skeleton." + }, + "server": { + "$ref": "#/$defs/server" + }, + "user": { + "type": "string", + "description": "User/login used in the authenticated remote URL." }, "org": { "type": "string" @@ -57,49 +168,20 @@ "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" - }, - "server": { - "$ref": "#/$defs/server" - }, - "user": { - "type": "string" - }, "valid": { "type": "string" }, - "scope": { - "type": "string", - "pattern": "^[rw?!-]{9}$" - }, "expires_at": { "type": "string" }, - "remotes": { - "type": "array", - "items": { - "$ref": "#/$defs/remote" - } + "scope": { + "$ref": "#/$defs/scope_permissions" + }, + "org_perm": { + "$ref": "#/$defs/org_permissions" + }, + "repo_perm": { + "$ref": "#/$defs/repo_permissions" } } } diff --git a/rvctl.py b/rvctl.py index d3f573b..2070c6e 100644 --- a/rvctl.py +++ b/rvctl.py @@ -53,6 +53,34 @@ class WorkspaceConfig: git: GitConfig +SCOPE_FIELDS = [ + ("a", "activitypub"), + ("A", "admin"), + ("i", "issue"), + ("m", "misc"), + ("n", "notification"), + ("o", "organization"), + ("p", "package"), + ("r", "repository"), + ("u", "user"), +] + +ORG_PERMISSION_FIELDS = [ + ("o", "owner"), + ("a", "admin"), + ("w", "write"), + ("r", "read"), + ("c", "create"), +] + +REPO_PERMISSION_FIELDS = [ + ("o", "owner"), + ("a", "admin"), + ("w", "write"), + ("r", "read"), +] + + def load_config(config_path: Path) -> WorkspaceConfig: raw = json.loads(config_path.read_text(encoding="utf-8")) base_dir = config_path.parent @@ -381,10 +409,17 @@ def normalize_remote_record(raw_remote: dict) -> dict | None: return None remote_record = {"name": remote_name, "org": "", "repo": ""} - for field_name in ["org", "repo", "org_perm", "repo_perm"]: + for field_name in ["org", "repo"]: field_value = raw_remote.get(field_name) if isinstance(field_value, str) and field_value: remote_record[field_name] = field_value + + org_perm = normalize_permission_map(raw_remote.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-") + if org_perm is not None: + remote_record["org_perm"] = org_perm + repo_perm = normalize_permission_map(raw_remote.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-") + if repo_perm is not None: + remote_record["repo_perm"] = repo_perm return remote_record @@ -392,36 +427,128 @@ def valid_mask(raw_value: str, allowed: str, width: int) -> bool: return len(raw_value) == width and all(char in allowed for char in raw_value) -def normalize_v3_token(config: WorkspaceConfig, raw_token: dict) -> dict | None: - if not isinstance(raw_token, dict): +def permission_map_from_mask( + raw_value: str, + fields: list[tuple[str, str]], + allowed: str, +) -> dict[str, str] | None: + if not valid_mask(raw_value, allowed, len(fields)): + return None + return {key: value for (key, _), value in zip(fields, raw_value)} + + +def normalize_permission_map( + raw_value, + fields: list[tuple[str, str]], + allowed: str, + unknown: str = "?", +) -> dict[str, str] | None: + if isinstance(raw_value, str): + return permission_map_from_mask(raw_value, fields, allowed) + if not isinstance(raw_value, 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") - if token_value is None: - token_value = raw_token.get("token") - if not isinstance(token_id, str) or not token_id: - return None - if not isinstance(token_value, str): - return None + normalized: dict[str, str] = {} + found = False + for key, long_name in fields: + field_value = raw_value.get(key, raw_value.get(long_name)) + if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed: + normalized[key] = field_value + found = True + else: + normalized[key] = unknown + return normalized if found else None + +def permission_map_to_mask( + raw_value, + fields: list[tuple[str, str]], + allowed: str, + unknown: str = "?", +) -> str: + if isinstance(raw_value, str) and valid_mask(raw_value, allowed, len(fields)): + return raw_value + if not isinstance(raw_value, dict): + return "" + + values = [] + for key, long_name in fields: + field_value = raw_value.get(key, raw_value.get(long_name)) + if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed: + values.append(field_value) + else: + values.append(unknown) + return "".join(values) + + +def normalize_flat_token( + config: WorkspaceConfig, + raw_token: dict, + remote_id: str, + token_value: str, + user_name: str, + org_name: str, + repo_name: str, + org_perm=None, + repo_perm=None, +) -> dict | None: server_record = normalize_server_record(config, raw_token.get("server", {})) if server_record is None: return None token_record = { - "token_id": token_id, + "id": remote_id, "value": token_value, "server": server_record, - "remotes": [], + "org": org_name, + "repo": repo_name, } - for field_name in ["user", "valid", "expires_at"]: + if user_name: + token_record["user"] = user_name + for field_name in ["valid", "expires_at"]: field_value = raw_token.get(field_name) if isinstance(field_value, str) and field_value: token_record[field_name] = field_value - scope_value = raw_token.get("scope") - if isinstance(scope_value, str) and valid_mask(scope_value, "rw?!-", 9): - token_record["scope"] = scope_value + + scope_record = normalize_permission_map(raw_token.get("scope"), SCOPE_FIELDS, "rw?!-") + if scope_record is not None: + token_record["scope"] = scope_record + + org_record = normalize_permission_map(org_perm if org_perm is not None else raw_token.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-") + if org_record is not None: + token_record["org_perm"] = org_record + repo_record = normalize_permission_map(repo_perm if repo_perm is not None else raw_token.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-") + if repo_record is not None: + token_record["repo_perm"] = repo_record + return token_record + + +def normalize_v3_tokens(config: WorkspaceConfig, raw_token: dict) -> list[dict]: + if not isinstance(raw_token, dict): + return [] + + remote_id = raw_token.get("id") or raw_token.get("remote") + legacy_token_id = raw_token.get("token_id") or raw_token.get("token_ref") + token_value = raw_token.get("value") + if token_value is None: + token_value = raw_token.get("token") + if not isinstance(token_value, str): + return [] + user_name = raw_token.get("user") + if not isinstance(user_name, str): + user_name = "" + + if isinstance(remote_id, str) and remote_id: + token_record = normalize_flat_token( + config, + raw_token, + remote_id, + token_value, + user_name, + str(raw_token.get("org", "")), + str(raw_token.get("repo", "")), + ) + return [token_record] if token_record is not None else [] raw_remotes = raw_token.get("remotes", []) if isinstance(raw_remotes, dict): @@ -430,34 +557,60 @@ def normalize_v3_token(config: WorkspaceConfig, raw_token: dict) -> dict | None: for remote_name, remote_value in raw_remotes.items() if isinstance(remote_value, dict) ] + if not isinstance(raw_remotes, list): + raw_remotes = [] + + normalized_records: list[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) + if remote_record is None: + continue + legacy_user_name = user_name + if not legacy_user_name and isinstance(legacy_token_id, str): + legacy_user_name = legacy_token_id + token_record = normalize_flat_token( + config, + raw_token, + remote_record["name"], + token_value, + legacy_user_name, + remote_record.get("org", ""), + remote_record.get("repo", ""), + remote_record.get("org_perm"), + remote_record.get("repo_perm"), + ) + if token_record is not None: + normalized_records.append(token_record) - return token_record + if not normalized_records and isinstance(legacy_token_id, str) and legacy_token_id: + token_record = normalize_flat_token( + config, + raw_token, + legacy_token_id, + token_value, + user_name, + str(raw_token.get("org", "")), + str(raw_token.get("repo", "")), + ) + if token_record is not None: + normalized_records.append(token_record) + return normalized_records 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", ""), + token_record.get("id", ""), ) for existing_record in tokens: existing_key = ( existing_record.get("server", {}).get("endpoint", ""), - existing_record.get("token_id", ""), - existing_record.get("value", ""), + existing_record.get("id", ""), ) 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) + existing_record.update(token_record) return tokens.append(token_record) @@ -504,27 +657,20 @@ def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> d if token_entry is None: continue token_value = token_entry_value(token_entry) + remote_name = token_entry_field(token_entry, "remote") or str(token_name) token_record = { - "token_id": str(token_name), + "id": remote_name, "value": token_value, "server": server_record_from_info(server_info), "user": str(user_name), - "remotes": [], + "org": token_entry_field(token_entry, "org"), + "repo": token_entry_field(token_entry, "repo"), } 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 @@ -534,8 +680,7 @@ 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: + for token_record in normalize_v3_tokens(config, raw_token): append_token_record(token_data, token_record) return token_data @@ -582,7 +727,7 @@ def write_token_store(config: WorkspaceConfig, token_data: dict) -> None: os.chmod(config.token_path.parent, 0o700) temp_path = config.token_path.with_suffix(config.token_path.suffix + ".tmp") - temp_path.write_text(json.dumps(token_data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temp_path.write_text(json.dumps(token_data, indent=2) + "\n", encoding="utf-8") os.chmod(temp_path, 0o600) temp_path.replace(config.token_path) os.chmod(config.token_path, 0o600) @@ -595,111 +740,99 @@ def token_server_endpoint(token_record: dict) -> str: return str(server_record.get("endpoint", "")) -def find_token_record(token_data: dict, endpoint: str, token_id: str) -> dict | None: +def token_record_id(token_record: dict) -> str: + return str(token_record.get("id", "")) + + +def find_token_record(token_data: dict, endpoint: str, remote_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: + if token_server_endpoint(token_record) == endpoint and token_record_id(token_record) == remote_id: return token_record return None -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 - - 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 apply_authz_to_token(token_record: dict, remote_record: dict, authz: dict) -> bool: +def apply_authz_to_token(token_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 + scope_value = authz.get("scope_mask") + if isinstance(scope_value, str): + scope_record = permission_map_from_mask(scope_value, SCOPE_FIELDS, "rw?!-") + if scope_record is not None and token_record.get("scope") != scope_record: + token_record["scope"] = scope_record + changed = True + 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 + if not isinstance(source_value, str): + continue + fields = ORG_PERMISSION_FIELDS if target_name == "org_perm" else REPO_PERMISSION_FIELDS + permission_record = permission_map_from_mask(source_value, fields, "+?!-") + if permission_record is not None and token_record.get(target_name) != permission_record: + token_record[target_name] = permission_record changed = True return changed -def register_token(token_data: dict, server_info: dict, remote_name: str, token_id: str, token_value: str) -> str: +def register_token( + token_data: dict, + server_info: dict, + remote_name: str, + user_name: str, + token_value: str, + hydrate: bool = True, +) -> str: endpoint = server_info["endpoint"] - token_record = find_token_record(token_data, endpoint, token_id) + token_record = find_token_record(token_data, endpoint, remote_name) 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" + token_record = { + "id": remote_name, + "value": token_value, + "server": server_record_from_info(server_info), + "user": user_name, + "org": str(server_info.get("org", "")), + "repo": str(server_info.get("repo", "")), + } + token_data.setdefault("tokens", []).append(token_record) + result = "added" else: result = "existing" if token_record.get("value") != token_value: token_record["value"] = token_value result = "updated" + if token_record.get("user") != user_name: + token_record["user"] = user_name + result = "updated" if token_record.get("server") != server_record_from_info(server_info): token_record["server"] = server_record_from_info(server_info) result = "updated" + for source_name, target_name in [("org", "org"), ("repo", "repo")]: + source_value = server_info.get(source_name) + if isinstance(source_value, str) and token_record.get(target_name) != source_value: + token_record[target_name] = source_value + 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" + if hydrate: + authz = load_gitea_authz( + endpoint, + remote_name, + token_value, + token_record.get("expires_at", ""), + token_record.get("org", ""), + token_record.get("repo", ""), + ) + if apply_authz_to_token(token_record, authz) and result == "existing": + result = "updated" return result @@ -804,7 +937,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: continue auth_urls += 1 - token_id, token_value = credentials + user_name, token_value = credentials row_index = len(remote_rows) remote_rows.append( { @@ -814,12 +947,13 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: "org": server_info["org"], "repo": server_info["repo"], "url_kind": "auth", - "token_id": token_id, + "token_id": remote_name, + "user": user_name, "token_value": token_value, "result": "found", } ) - found_credentials.append((row_index, server_info, remote_name, token_id, token_value)) + found_credentials.append((row_index, server_info, remote_name, user_name, token_value)) return { "repo_root": repo_root, @@ -848,8 +982,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, token_id, token_value in found_credentials: - result = register_token(token_data, server_info, remote_name, token_id, token_value) + 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) if result == "added": added += 1 elif result == "updated": @@ -866,6 +1000,67 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: return report +def sync_token_from_remote(config: WorkspaceConfig, repo_path: Path, remote_name: str, dry_run: bool = False) -> dict: + repo_root = git_repo_root(repo_path) + if repo_root is None: + raise SystemExit(f"Missing git repo at path: {repo_path}") + + remote_url = git_capture(repo_root, ["remote", "get-url", remote_name]) + if not remote_url: + raise SystemExit(f"Missing git remote: {remote_name}") + server_info = server_info_from_url(config, remote_url) + if server_info is None: + raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}") + credentials = remote_credentials(remote_url) + if credentials is None: + raise SystemExit(f"Remote '{remote_name}' has no credentials.") + + user_name, token_value = credentials + result = "dry-run" + if not dry_run: + token_data = load_token_store(config) + result = register_token(token_data, server_info, remote_name, user_name, token_value, hydrate=False) + write_token_store(config, token_data) + + return { + "repo_root": str(repo_root), + "remote": remote_name, + "server": server_info["endpoint"], + "user": user_name, + "org": server_info.get("org", ""), + "repo": server_info.get("repo", ""), + "status": result, + } + + +def hydrate_token_from_api(config: WorkspaceConfig, remote_name: str, endpoint: str | None, dry_run: bool = False) -> dict: + token_data = load_token_store(config, write_normalized=not dry_run) + token_record = resolve_token_from_store(token_data, endpoint, remote_name, None) + token_value = str(token_record.get("value", "")) + if not token_value: + raise SystemExit(f"Token value is empty in tokens.json: {token_server_endpoint(token_record)}:{remote_name}") + + authz = load_gitea_authz( + token_server_endpoint(token_record), + remote_name, + token_value, + token_record.get("expires_at", ""), + token_record.get("org", ""), + token_record.get("repo", ""), + ) + changed = apply_authz_to_token(token_record, authz) + if changed and not dry_run: + write_token_store(config, token_data) + + return { + "remote": remote_name, + "server": token_server_endpoint(token_record), + "user": str(token_record.get("user", "")), + "valid": str(token_record.get("valid", "")), + "status": "dry-run" if dry_run else ("updated" if changed else "unchanged"), + } + + def count_server_tokens(server_entry: dict) -> int: return len(server_entry.get("tokens", [])) @@ -900,28 +1095,25 @@ def store_servers_from_tokens(token_data: dict) -> dict[str, dict]: 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", "")), - } - ) + token_id = token_record_id(token_record) + if not token_id: + continue + server_entry["tokens"].append( + { + "endpoint": endpoint, + "token_id": token_id, + "token_value": str(token_record.get("value", "")), + "user": str(token_record.get("user", "")), + "valid": str(token_record.get("valid", "")), + "scope_mask": permission_map_to_mask(token_record.get("scope"), SCOPE_FIELDS, "rw?!-"), + "expires_at": str(token_record.get("expires_at", "")), + "remote": token_id, + "owner": str(token_record.get("org", "")), + "repo_name": str(token_record.get("repo", "")), + "org_mask": permission_map_to_mask(token_record.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-"), + "repo_mask": permission_map_to_mask(token_record.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-"), + } + ) return servers @@ -938,7 +1130,7 @@ def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str, token_pairs.add( ( str(row.get("remote", "")), - str(token_id), + str(row.get("user", "")), str(token_value), str(row.get("org", row.get("owner", ""))), str(row.get("repo", row.get("repo_name", ""))), @@ -997,13 +1189,14 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup continue entry["auth_urls"] += 1 - token_id, token_value = credentials + user_name, token_value = credentials entry["tokens"].append( { "remote": remote_name, "org": server_info["org"], "repo": server_info["repo"], - "token_id": token_id, + "token_id": remote_name, + "user": user_name, "token_value": token_value, } ) @@ -1189,6 +1382,7 @@ def repo_token_rows( "owner": row.get("org", ""), "repo_name": row.get("repo", ""), "token_id": row.get("token_id", ""), + "user": row.get("user", ""), "token_value": row.get("token_value", ""), } for row in scan_rows @@ -1208,6 +1402,7 @@ def repo_token_rows( "owner": row.get("org", ""), "repo_name": row.get("repo", ""), "token_id": row.get("token_id", ""), + "user": row.get("user", ""), "token_value": row.get("token_value", ""), } ) @@ -1259,20 +1454,9 @@ def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: boo def scope_mask(scopes: list[str]) -> str: scope_set = {scope.lower() for scope in scopes} - categories = [ - "activitypub", - "admin", - "issue", - "misc", - "notification", - "organization", - "package", - "repository", - "user", - ] values = [] - for category in categories: + for _, category in SCOPE_FIELDS: if f"write:{category}" in scope_set: values.append("w") elif f"read:{category}" in scope_set: @@ -1448,6 +1632,7 @@ def print_token_item_rows( if store_row: repo_fields_match = ( store_row["token_id"] == row["token_id"] + and store_row["user"] == row.get("user", "") and store_row["token_value"] == token_value and store_row["owner"] == row["owner"] and store_row["repo_name"] == row["repo_name"] @@ -1458,7 +1643,7 @@ def print_token_item_rows( marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*" append_token_row( endpoint, - synced_store_row.get("user", "") if synced_store_row else "", + synced_store_row.get("user", "") if synced_store_row else row.get("user", ""), row["remote"], token_id, token_value, @@ -1491,7 +1676,7 @@ def print_token_item_rows( def resolve_token_from_store( token_data: dict, endpoint: str | None, - token_id: str | None, + remote_id: str | None, user_name: str | None, ) -> dict: candidates = [] @@ -1499,7 +1684,7 @@ def resolve_token_from_store( 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: + if remote_id and token_record_id(token_record) != remote_id: continue if user_name and token_record.get("user") != user_name: continue @@ -1510,11 +1695,11 @@ def resolve_token_from_store( if len(candidates) > 1: available = ", ".join( sorted( - f"{token_server_endpoint(token_record)}:{token_record.get('token_id', '')}" + f"{token_server_endpoint(token_record)}:{token_record_id(token_record)}" for token_record in candidates ) ) - raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name. Available: {available}") + raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name or --remote. Available: {available}") return candidates[0] @@ -1585,10 +1770,10 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: print(f"host\t{server_entry.get('host', '')}") print(f"port\t{server_entry.get('port', '')}") print(f"tokens\t{count_server_tokens(server_entry)}") - for row in sorted(server_entry.get("tokens", []), key=lambda item: (item.get("token_id", ""), item.get("remote", ""))): + for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")): 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}") + print(f"id\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: @@ -1622,6 +1807,26 @@ def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None: print_status_counts(status_counts) +def print_token_action_result(result: dict[str, str]) -> None: + for field_name in ["repo_root", "remote", "server", "user", "org", "repo", "valid", "status"]: + field_value = result.get(field_name) + if field_value is not None: + print(f"{field_name}\t{field_value}") + + +def run_tokens_sync(config: WorkspaceConfig, args: argparse.Namespace) -> None: + if args.direction == "remote": + result = sync_token_from_remote(config, resolve_repo_argument(args.repo), args.remote_id, args.dry_run) + print_token_action_result(result) + return + if args.direction == "store": + args.remote = args.remote_id + args.token_name = args.remote_id + run_tokens_write(config, args) + return + raise SystemExit(f"Unsupported tokens sync direction: {args.direction}") + + def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None: server_url = (args.server or config.git.base_url).rstrip("/") server_info = server_info_from_url(config, server_url) @@ -1629,32 +1834,29 @@ def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None: raise SystemExit(f"Only http/https server URLs are supported: {server_url}") token_data = load_token_store(config) - existing_token = find_token_record(token_data, server_info["endpoint"], args.token_id) + remote_id = args.token_id + if args.remote and args.remote != remote_id: + raise SystemExit("Token id must match --remote because tokens.json is keyed 1:1 by git remote name.") + + existing_token = find_token_record(token_data, server_info["endpoint"], remote_id) if existing_token is not None: - raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{args.token_id}") + raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{remote_id}") token_record = { - "token_id": args.token_id, + "id": remote_id, "value": args.value or "", "server": server_record_from_info(server_info), - "remotes": [], + "org": args.org or "", + "repo": args.repo_name or "", } if args.user: token_record["user"] = args.user - if args.remote: - token_record["remotes"].append( - { - "name": args.remote, - "org": args.org or "", - "repo": args.repo_name or "", - } - ) print(f"token_path\t{config.token_path}") print(f"server\t{server_info['endpoint']}") - print(f"token_id\t{args.token_id}") + print(f"id\t{remote_id}") print(f"value\t{'set' if token_record['value'] else 'empty'}") - print(f"remote\t{args.remote or ''}") + print(f"remote\t{remote_id}") print(f"status\t{'dry-run' if args.dry_run else 'added'}") if args.dry_run: @@ -1686,19 +1888,22 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: token_record = resolve_token_from_store( token_data, args.server or target_server_info["endpoint"], - args.token_name, + args.token_name or args.remote, args.user, ) server_endpoint = token_server_endpoint(token_record) - token_id = str(token_record.get("token_id", "")) + remote_id = token_record_id(token_record) + user_name = str(token_record.get("user", "")) token_value = str(token_record.get("value", "")) if not token_value: - raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{token_id}") + raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{remote_id}") + if not user_name: + raise SystemExit(f"Token user is empty in tokens.json: {server_endpoint}:{remote_id}") - final_url = credentialed_remote_url(target_url, token_id, token_value) + final_url = credentialed_remote_url(target_url, user_name, 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 == (token_id, token_value): + if existing_credentials == (user_name, token_value): status = "unchanged" elif existing_credentials is not None and not args.replace: raise SystemExit( @@ -1709,8 +1914,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{token_record.get('user', '')}") - print(f"token_name\t{token_id}") + print(f"user\t{user_name}") + print(f"id\t{remote_id}") print(f"status\t{status}") print(f"url\t{sanitize_remote_url(final_url)[0]}") @@ -1721,6 +1926,11 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None: + if args.remote_id: + result = hydrate_token_from_api(config, args.remote_id, args.server, args.dry_run) + print_token_action_result(result) + return + if args.from_source == "remotes": if args.dry_run: run_tokens_scan(config, args) @@ -1736,7 +1946,7 @@ def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None if args.from_source == "store": run_tokens_write(config, args) return - raise SystemExit(f"Unsupported tokens update source: {args.from_source}") + raise SystemExit("Pass REMOTE_ID for API metadata refresh or use legacy --from remotes|store.") def git_current_branch(config: WorkspaceConfig, repo_path: Path) -> str: @@ -1959,7 +2169,7 @@ 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="API user recorded on the token.") - parser.add_argument("--token-name", help="Token id inside tokens.json, for example 't1'.") + parser.add_argument("--token-name", help="Remote id inside tokens.json, for example 'r1'. Default: --remote.") def print_table(headers: list[str], rows: list[list[str]]) -> None: @@ -2017,7 +2227,7 @@ def print_main_overview(config_path: Path) -> None: ["list-cards", "[series]", "list cards in a selected series"], ["tmux-container", "[series] [card]", "start tmux with container in pane 0"], ["submission", "[series] [card] --class K --nick N", "prepare answer repo and student branch"], - ["tokens", "scan|add|read|stats|write|update", "manage tokens.json and Git remote credentials"], + ["tokens", "scan|sync|update|add|read|stats|write", "manage tokens.json and Git remote credentials"], ], ) print() @@ -2054,11 +2264,13 @@ 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"], - ["add", "TOKEN_ID", "tokens.json", "add an empty token skeleton for manual editing"], - ["read", "[--server ENDPOINT]", "tokens.json", "show servers, token ids and remotes"], + ["add", "REMOTE_ID", "tokens.json", "add an empty token skeleton for manual editing"], + ["read", "[--server ENDPOINT]", "tokens.json", "show servers, remote ids and tokens"], ["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"], + ["sync remote", "REMOTE_ID [--repo PATH]", "repo -> tokens.json", "copy one remote URL into the store"], + ["sync store", "REMOTE_ID [--repo PATH]", "tokens.json -> repo", "write one store record into a remote URL"], ["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"], + ["update", "REMOTE_ID", "API -> tokens.json", "refresh valid/scope/org/repo metadata"], ], ) print() @@ -2067,9 +2279,12 @@ def print_tokens_overview() -> None: ["case", "command"], [ ["scan current repo", "./rvctl tokens scan"], + ["read remote r1 into store", "./rvctl tokens sync remote r1"], + ["refresh r1 metadata", "./rvctl tokens update r1"], + ["write store r1 to remote", "./rvctl tokens sync store r1 --repo PATH"], ["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 --token-name t1"], + ["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL"], ], ) @@ -2177,11 +2392,11 @@ def build_parser() -> argparse.ArgumentParser: "add", help="Add an empty token skeleton to tokens.json.", ) - tokens_add_parser.add_argument("token_id", help="Token id, for example 't1' or 'r6'.") + tokens_add_parser.add_argument("token_id", metavar="REMOTE_ID", help="Remote id, for example 'r1' or 'r1a'.") tokens_add_parser.add_argument("--server", help="Server endpoint. Default: git.base_url from workspace.json.") tokens_add_parser.add_argument("--value", default="", help="Optional token value. Default: empty for manual editing.") tokens_add_parser.add_argument("--user", help="Optional API user label.") - tokens_add_parser.add_argument("--remote", help="Optional remote name to attach to this token.") + tokens_add_parser.add_argument("--remote", help="Compatibility alias. If used, it must match REMOTE_ID.") tokens_add_parser.add_argument("--org", help="Optional remote organization.") tokens_add_parser.add_argument("--repo", dest="repo_name", help="Optional remote repository.") tokens_add_parser.add_argument("--dry-run", action="store_true", help="Print the planned token without writing it.") @@ -2193,6 +2408,18 @@ def build_parser() -> argparse.ArgumentParser: add_repo_option(tokens_stats_parser) tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.") + tokens_sync_parser = tokens_subparsers.add_parser( + "sync", + help="Synchronize one remote id between git remote URL and tokens.json.", + ) + tokens_sync_parser.add_argument("direction", choices=["remote", "store"], help="'remote' reads git remote into store; 'store' writes store into git remote.") + tokens_sync_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.") + add_repo_option(tokens_sync_parser) + tokens_sync_parser.add_argument("--url", help="Target remote URL used when direction is 'store' and the remote does not exist yet.") + add_store_selection_options(tokens_sync_parser) + tokens_sync_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing store -> remote.") + tokens_sync_parser.add_argument("--dry-run", action="store_true", help="Print the planned sync without writing it.") + tokens_write_parser = tokens_subparsers.add_parser( "write", help="Write credentials from tokens.json into a repo remote URL.", @@ -2206,14 +2433,14 @@ def build_parser() -> argparse.ArgumentParser: tokens_update_parser = tokens_subparsers.add_parser( "update", - help="Synchronize token data in a selected direction.", + help="Refresh API metadata for one remote id, or use a legacy --from direction.", ) + tokens_update_parser.add_argument("remote_id", nargs="?", help="Remote id to refresh from API, for example 'r1'.") tokens_update_parser.add_argument( "--from", dest="from_source", - required=True, choices=["remotes", "store"], - help="Synchronization direction source: 'remotes' or 'store'.", + help="Legacy synchronization direction source: 'remotes' or 'store'.", ) add_repo_option(tokens_update_parser) tokens_update_parser.add_argument("--remote", help="Remote name used when --from store.") @@ -2266,6 +2493,9 @@ def main() -> int: if args.tokens_command == "stats": run_tokens_stats(config, args) return 0 + if args.tokens_command == "sync": + run_tokens_sync(config, args) + return 0 if args.tokens_command == "write": run_tokens_write(config, args) return 0