Restructure token store by remote id

This commit is contained in:
mpabi
2026-04-26 22:12:45 +02:00
parent d2d200257f
commit 55c52c7b4f
5 changed files with 768 additions and 352 deletions
+14 -14
View File
@@ -86,7 +86,8 @@ Masz dwie drogi.
To jest wariant dydaktyczny, jesli uczen ma cwiczyc reczne dodawanie remota z To jest wariant dydaktyczny, jesli uczen ma cwiczyc reczne dodawanie remota z
tokenem do konkretnego zdalnego endpointu. Jesli launcher zobaczy URL w formacie 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: Przyklad:
@@ -115,7 +116,7 @@ Minimalny format:
"version": 3, "version": 3,
"tokens": [ "tokens": [
{ {
"token_id": "t1", "id": "r1",
"value": "TU_WSTAW_TOKEN", "value": "TU_WSTAW_TOKEN",
"server": { "server": {
"type": "gitea", "type": "gitea",
@@ -124,15 +125,11 @@ Minimalny format:
"host": "77.90.8.171", "host": "77.90.8.171",
"port": 3001 "port": 3001
}, },
"remotes": [ "user": "u1",
{
"name": "r1",
"org": "edu-tools", "org": "edu-tools",
"repo": "rv-launcher" "repo": "rv-launcher"
} }
] ]
}
]
} }
``` ```
@@ -159,7 +156,10 @@ Podstawowe komendy tokenow:
```bash ```bash
./rvctl tokens scan ./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 read
./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ./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 `tokens scan` wypisuje tabele `tokens` i niczego nie zapisuje: jeden wiersz na
logiczny remote tokena. logiczny remote tokena.
`token_ref` laczy nazwe tokena z markerem po prawej stronie: `*` oznacza, ze `token_ref` laczy nazwe tokena z markerem po prawej stronie: `*` oznacza, ze
remote i `tokens.json` sa zgodne oraz uprawnienia zostaly wczytane, `R` oznacza remote i `tokens.json` sa zgodne, `R` oznacza token tylko w remote, a `S`
token tylko w remote, a `S` token tylko w `tokens.json`. Kolumny `scope`, `org` token tylko w `tokens.json`. Kolumny `scope`, `org` i `repo` sa maskami
i `repo` sa maskami uprawnien; bez `*` maja wartosc `?????????`, `?????` uprawnien; bez zgodnego wpisu maja wartosc `?????????`, `?????` albo `????`.
albo `????`. Zapis z remote do `tokens.json` robi `tokens sync remote r1`.
Zapis z remote do `tokens.json` oraz odczyt `valid` i uprawnien z API robi Odczyt `valid` i uprawnien z API robi `tokens update r1`.
dopiero `tokens update --from remotes`. Zapis z `tokens.json` do remota robi `tokens sync store r1`.
Kolumna `valid` oznacza, czy token zostal zaakceptowany przez API teraz. Kolumna `valid` oznacza, czy token zostal zaakceptowany przez API teraz.
Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` moze pokazac Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` moze pokazac
date wygasniecia; bez daty poprawny token pokazuje `forever`. date wygasniecia; bez daty poprawny token pokazuje `forever`.
+95 -33
View File
@@ -103,22 +103,23 @@ Minimalny format pliku:
```json ```json
{ {
"version": 2, "version": 3,
"servers": { "tokens": [
"http://77.90.8.171:3001": { {
"id": "r1",
"value": "TU_WSTAW_TOKEN",
"server": {
"type": "gitea", "type": "gitea",
"endpoint": "http://77.90.8.171:3001",
"scheme": "http", "scheme": "http",
"host": "77.90.8.171", "host": "77.90.8.171",
"port": 3001, "port": 3001
"users": { },
"u1": { "user": "u1",
"tokens": { "org": "edu-tools",
"t1": "TU_WSTAW_TOKEN" "repo": "rv-launcher"
}
}
}
}
} }
]
} }
``` ```
@@ -306,13 +307,13 @@ Typowy wynik:
tokens tokens
item server proto host org repo user remote token_ref token valid scope org repo item server proto host org repo user remote token_ref token valid scope org repo
---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr-- ---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- 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 `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 po prawej. Nazwa tokena jest taka sama jak nazwa git remote, np. `r1`. Marker
oraz uprawnienia zostaly wczytane. Marker `R` oznacza token tylko w remote, a `*` oznacza, ze remote i `tokens.json` sa zgodne. Marker `R` oznacza token tylko
`S` token tylko w `tokens.json`. w remote, a `S` token tylko w `tokens.json`.
Maski uprawnien: Maski uprawnien:
@@ -330,10 +331,11 @@ Przyklad:
./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 ./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 Dodaje pusty szkielet tokena do `tokens.json`. `REMOTE_ID` musi byc taki sam
je uzupelnic recznie przed uzyciem tokena. jak nazwa git remote, np. `r1`. Pole `value` jest puste i trzeba je uzupelnic
recznie przed uzyciem tokena.
Przelaczniki: Przelaczniki:
@@ -342,9 +344,9 @@ Przelaczniki:
- `--value TOKEN` - `--value TOKEN`
Opcjonalna wartosc tokena. Domyslnie pusta. Opcjonalna wartosc tokena. Domyslnie pusta.
- `--user NAME` - `--user NAME`
Opcjonalny opis usera API. Login uzywany w URL-u auth, np. `u1`.
- `--remote NAME` - `--remote NAME`
Opcjonalny remote powiazany z tokenem. Alias zgodnosci. Jesli podany, musi byc taki sam jak `REMOTE_ID`.
- `--org NAME` - `--org NAME`
Opcjonalna organizacja dla remota. Opcjonalna organizacja dla remota.
- `--repo NAME` - `--repo NAME`
@@ -355,8 +357,51 @@ Przelaczniki:
Przyklady: Przyklady:
```bash ```bash
./rvctl tokens add r6 ./rvctl tokens add r1
./rvctl tokens add t1 --remote r1 --org edu-tools --repo rv-launcher ./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` ## `tokens read`
@@ -379,10 +424,9 @@ type<TAB>gitea
scheme<TAB>http scheme<TAB>http
host<TAB>77.90.8.171 host<TAB>77.90.8.171
port<TAB>3001 port<TAB>3001
users<TAB>1
tokens<TAB>1 tokens<TAB>1
user<TAB>u1 id<TAB>r1<TAB>SE****23
token<TAB>t1<TAB>SE****23 user<TAB>r1<TAB>u1
``` ```
Przyklad: Przyklad:
@@ -415,7 +459,7 @@ token_path<TAB>...
tokens tokens
item server proto host org repo user remote token_ref token valid scope org repo item server proto host org repo user remote token_ref token valid scope org repo
---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- aAimnopru oawrc- oawr-- ---- ------ ----- ------------------ --------- ----------- ---- ------ --------- ------------ ------------------- 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 status
item<TAB>value item<TAB>value
@@ -445,7 +489,7 @@ Przelaczniki:
- `--user NAME` - `--user NAME`
Uzytkownik z wybranego endpointu. Uzytkownik z wybranego endpointu.
- `--token-name NAME` - `--token-name NAME`
Nazwa tokena, na przyklad `t1`. Remote id w `tokens.json`, na przyklad `r1`. Domyslnie wartosc `--remote`.
- `--replace` - `--replace`
Nadpisuje inne dane auth juz wpisane w remote URL. Nadpisuje inne dane auth juz wpisane w remote URL.
- `--dry-run` - `--dry-run`
@@ -454,12 +498,30 @@ Przelaczniki:
Przyklad: Przyklad:
```bash ```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: Przelaczniki:
@@ -478,7 +540,7 @@ Przelaczniki:
- `--user NAME` - `--user NAME`
Opcjonalny wybor usera dla `--from store`. Opcjonalny wybor usera dla `--from store`.
- `--token-name NAME` - `--token-name NAME`
Opcjonalny wybor tokena dla `--from store`. Opcjonalny wybor remote id dla `--from store`.
- `--replace` - `--replace`
Nadpisuje inne auth przy `--from store`. Nadpisuje inne auth przy `--from store`.
- `--dry-run` - `--dry-run`
@@ -488,7 +550,7 @@ Przyklady:
```bash ```bash
./rvctl tokens update --from remotes --repo ~/dev/workspace/rv/series/inf/03 ./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]` ## `submission [series] [card]`
+104 -62
View File
@@ -4,28 +4,31 @@ Plik opisuje model pracy z tokenami w launcherze.
## Zrodlo prawdy ## 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` - lokalny plik `~/dev/workspace/rv/tokens/tokens.json`
`tokens scan` tylko porownuje oba zrodla i niczego nie zapisuje. `tokens scan` tylko porownuje oba miejsca i niczego nie zapisuje.
`tokens update --from remotes` czyta remote URL-e, zapisuje tokeny do `tokens sync remote r1` czyta remote `r1` i zapisuje jego dane w
`tokens.json` i wzbogaca je danymi z API, jezeli token dziala. `tokens.json`.
`tokens update --from store` zapisuje wybrany token z `tokens.json` do remote `tokens sync store r1` zapisuje rekord `r1` z `tokens.json` do git remote
URL-a repo. `r1`.
`tokens update r1` nie synchronizuje sekretu; tylko pobiera z API metadane
tokenu: `valid`, `scope`, `org_perm` i `repo_perm`.
## Format `tokens.json` ## Format `tokens.json`
Aktualny format to `version: 3`. Glownym rekordem jest token. `server`, `user`, Aktualny format to `version: 3`. Glownym rekordem jest remote-token. Pole
`valid`, `scope` i `remotes` sa atrybutami tego tokena. `id` jest obowiazkowe i musi byc takie samo jak nazwa git remote, np. `r1`
albo `r1a`.
```json ```json
{ {
"version": 3, "version": 3,
"tokens": [ "tokens": [
{ {
"token_id": "t1", "id": "r1",
"value": "SECRET", "value": "SECRET",
"server": { "server": {
"type": "gitea", "type": "gitea",
@@ -35,17 +38,33 @@ Aktualny format to `version: 3`. Glownym rekordem jest token. `server`, `user`,
"port": 3001 "port": 3001
}, },
"user": "u1", "user": "u1",
"valid": "forever",
"scope": "-----w---",
"remotes": [
{
"name": "r1",
"org": "edu-tools", "org": "edu-tools",
"repo": "rv-launcher", "repo": "rv-launcher",
"org_perm": "+++++", "valid": "forever",
"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: 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.endpoint` - endpoint serwera, na przyklad `http://77.90.8.171:3001`
- `server.type` - typ serwera, na przyklad `gitea`, `github`, `gitlab`, `unknown` - `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 - `value` - sekret tokena
- `remotes[].name` - nazwa remota, na przyklad `r1` - `org` - organizacja z URL-a
- `remotes[].org` - organizacja z URL-a - `repo` - repo z URL-a
- `remotes[].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 `!` - `valid` - `forever`, data `expires_at`, `invalid`, `?` albo `!`
- `scope` - maska scope tokena w kolejnosci `aAimnopru` - `scope` - mapa scope tokena
- `remotes[].org_perm` - maska praw w organizacji - `org_perm` - mapa praw w organizacji
- `remotes[].repo_perm` - maska praw w repo - `repo_perm` - mapa praw w repo
## Porownanie ## Porownanie
Porownanie z `git remote -v` jest robione po: Porownanie z `git remote -v` jest robione po:
```text ```text
server.endpoint + remote.name + token_id + value + org + repo server.endpoint + id + user + value + org + repo
``` ```
Znacznik w kolumnie `token_ref`: Znacznik w kolumnie `token_ref`:
@@ -88,10 +106,13 @@ Znacznik w kolumnie `token_ref`:
- `S` - wpis istnieje tylko w `tokens.json` - `S` - wpis istnieje tylko w `tokens.json`
- `!` - remote i `tokens.json` sa zgodne, ale zapisany token jest `invalid` albo ma blad walidacji - `!` - 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. 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: Naglowki masek:
@@ -119,7 +140,22 @@ Znaki w `scope`:
- `?` - nie wczytano - `?` - nie wczytano
- `!` - blad wczytania - `!` - 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 wlaczona
- `-` - flaga wylaczona - `-` - flaga wylaczona
@@ -130,26 +166,43 @@ Znaki w `org` i `repo`:
### `tokens scan` ### `tokens scan`
Read-only. Czyta remote URL-e i `tokens.json`, a potem wypisuje tabele `tokens`. Read-only. Czyta remote URL-e i `tokens.json`, a potem wypisuje tabele
Nie tworzy i nie modyfikuje `tokens.json`. `tokens`. Nie tworzy i nie modyfikuje `tokens.json`.
```bash ```bash
./rvctl tokens scan ./rvctl tokens scan
./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 ./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 Kopiuje dane dostepowe z git remote do `tokens.json`. Nie pobiera metadanych z
v3 i probuje pobrac pola API: `user`, `valid`, `scope`, `org_perm`, API.
`repo_perm`.
```bash ```bash
./rvctl tokens update --from remotes --repo ~/dev/workspace/rv/tools/rv-launcher ./rvctl tokens sync remote r1
./rvctl tokens update --from remotes --dry-run ./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` ### `tokens read`
@@ -163,16 +216,16 @@ Pokazuje zawartosc `tokens.json`.
### `tokens add` ### `tokens add`
Dodaje pusty szkielet tokena do `tokens.json`. Pole `value` zostaje puste, zeby Dodaje pusty szkielet rekordu do `tokens.json`. `REMOTE_ID` musi odpowiadac
mozna bylo recznie wkleic sekret. nazwie git remote.
```bash ```bash
./rvctl tokens add t1 ./rvctl tokens add r1
./rvctl tokens add r6 --server http://77.90.8.171:3001 ./rvctl tokens add r1 --server http://77.90.8.171:3001 --user u1 --org edu-tools --repo rv-launcher
./rvctl tokens add t1 --remote r1 --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` ### `tokens stats`
@@ -182,23 +235,12 @@ Pokazuje kontekst, tabele `tokens` i podsumowanie statusow endpointow.
./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ./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 ```bash
./rvctl tokens write \ ./rvctl tokens update --from remotes --repo PATH
--repo ~/dev/workspace/rv/series/inf/03 \ ./rvctl tokens update --from store --repo PATH --remote r1 --server http://77.90.8.171:3001
--remote r1 \ ./rvctl tokens write --repo PATH --remote r1 --server http://77.90.8.171:3001
--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
``` ```
+123 -41
View File
@@ -42,14 +42,125 @@
} }
} }
}, },
"remote": { "scope_permissions": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["name", "org", "repo"], "required": ["a", "A", "i", "m", "n", "o", "p", "r", "u"],
"properties": { "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", "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": { "org": {
"type": "string" "type": "string"
@@ -57,49 +168,20 @@
"repo": { "repo": {
"type": "string" "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": { "valid": {
"type": "string" "type": "string"
}, },
"scope": {
"type": "string",
"pattern": "^[rw?!-]{9}$"
},
"expires_at": { "expires_at": {
"type": "string" "type": "string"
}, },
"remotes": { "scope": {
"type": "array", "$ref": "#/$defs/scope_permissions"
"items": { },
"$ref": "#/$defs/remote" "org_perm": {
} "$ref": "#/$defs/org_permissions"
},
"repo_perm": {
"$ref": "#/$defs/repo_permissions"
} }
} }
} }
+402 -172
View File
@@ -53,6 +53,34 @@ class WorkspaceConfig:
git: GitConfig 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: def load_config(config_path: Path) -> WorkspaceConfig:
raw = json.loads(config_path.read_text(encoding="utf-8")) raw = json.loads(config_path.read_text(encoding="utf-8"))
base_dir = config_path.parent base_dir = config_path.parent
@@ -381,10 +409,17 @@ def normalize_remote_record(raw_remote: dict) -> dict | None:
return None return None
remote_record = {"name": remote_name, "org": "", "repo": ""} 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) field_value = raw_remote.get(field_name)
if isinstance(field_value, str) and field_value: if isinstance(field_value, str) and field_value:
remote_record[field_name] = 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 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) 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: def permission_map_from_mask(
if not isinstance(raw_token, dict): 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 return None
token_id = raw_token.get("token_id") or raw_token.get("id") or raw_token.get("token_ref") normalized: dict[str, str] = {}
token_value = raw_token.get("value") found = False
if token_value is None: for key, long_name in fields:
token_value = raw_token.get("token") field_value = raw_value.get(key, raw_value.get(long_name))
if not isinstance(token_id, str) or not token_id: if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
return None normalized[key] = field_value
if not isinstance(token_value, str): found = True
return None 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", {})) server_record = normalize_server_record(config, raw_token.get("server", {}))
if server_record is None: if server_record is None:
return None return None
token_record = { token_record = {
"token_id": token_id, "id": remote_id,
"value": token_value, "value": token_value,
"server": server_record, "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) field_value = raw_token.get(field_name)
if isinstance(field_value, str) and field_value: if isinstance(field_value, str) and field_value:
token_record[field_name] = 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): scope_record = normalize_permission_map(raw_token.get("scope"), SCOPE_FIELDS, "rw?!-")
token_record["scope"] = scope_value 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", []) raw_remotes = raw_token.get("remotes", [])
if isinstance(raw_remotes, dict): 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() for remote_name, remote_value in raw_remotes.items()
if isinstance(remote_value, dict) if isinstance(remote_value, dict)
] ]
if not isinstance(raw_remotes, list):
raw_remotes = []
normalized_records: list[dict] = []
if isinstance(raw_remotes, list): if isinstance(raw_remotes, list):
for raw_remote in raw_remotes: for raw_remote in raw_remotes:
remote_record = normalize_remote_record(raw_remote) remote_record = normalize_remote_record(raw_remote)
if remote_record is not None: if remote_record is None:
token_record["remotes"].append(remote_record) 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: def append_token_record(token_data: dict, token_record: dict) -> None:
tokens = token_data.setdefault("tokens", []) tokens = token_data.setdefault("tokens", [])
token_key = ( token_key = (
token_record.get("server", {}).get("endpoint", ""), token_record.get("server", {}).get("endpoint", ""),
token_record.get("token_id", ""), token_record.get("id", ""),
token_record.get("value", ""),
) )
for existing_record in tokens: for existing_record in tokens:
existing_key = ( existing_key = (
existing_record.get("server", {}).get("endpoint", ""), existing_record.get("server", {}).get("endpoint", ""),
existing_record.get("token_id", ""), existing_record.get("id", ""),
existing_record.get("value", ""),
) )
if existing_key == token_key: if existing_key == token_key:
existing_remotes = existing_record.setdefault("remotes", []) existing_record.update(token_record)
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 return
tokens.append(token_record) 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: if token_entry is None:
continue continue
token_value = token_entry_value(token_entry) token_value = token_entry_value(token_entry)
remote_name = token_entry_field(token_entry, "remote") or str(token_name)
token_record = { token_record = {
"token_id": str(token_name), "id": remote_name,
"value": token_value, "value": token_value,
"server": server_record_from_info(server_info), "server": server_record_from_info(server_info),
"user": str(user_name), "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) expires_at = token_entry_expires_at(token_entry)
if expires_at: if expires_at:
token_record["expires_at"] = 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) append_token_record(token_data, token_record)
return token_data 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): if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list):
token_data = empty_token_store() token_data = empty_token_store()
for raw_token in raw_data["tokens"]: for raw_token in raw_data["tokens"]:
token_record = normalize_v3_token(config, raw_token) for token_record in normalize_v3_tokens(config, raw_token):
if token_record is not None:
append_token_record(token_data, token_record) append_token_record(token_data, token_record)
return token_data return token_data
@@ -582,7 +727,7 @@ def write_token_store(config: WorkspaceConfig, token_data: dict) -> None:
os.chmod(config.token_path.parent, 0o700) os.chmod(config.token_path.parent, 0o700)
temp_path = config.token_path.with_suffix(config.token_path.suffix + ".tmp") 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) os.chmod(temp_path, 0o600)
temp_path.replace(config.token_path) temp_path.replace(config.token_path)
os.chmod(config.token_path, 0o600) os.chmod(config.token_path, 0o600)
@@ -595,110 +740,98 @@ def token_server_endpoint(token_record: dict) -> str:
return str(server_record.get("endpoint", "")) 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", []): 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 token_record
return None return None
def find_orphan_token_by_value(token_data: dict, endpoint: str, token_value: str) -> dict | None: def apply_authz_to_token(token_record: dict, authz: dict) -> bool:
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:
changed = False changed = False
for source_name, target_name in [ for source_name, target_name in [
("user", "user"), ("user", "user"),
("valid", "valid"), ("valid", "valid"),
("scope_mask", "scope"),
]: ]:
source_value = authz.get(source_name) source_value = authz.get(source_name)
if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value: if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value:
token_record[target_name] = source_value token_record[target_name] = source_value
changed = True 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 [ for source_name, target_name in [
("org_mask", "org_perm"), ("org_mask", "org_perm"),
("repo_mask", "repo_perm"), ("repo_mask", "repo_perm"),
]: ]:
source_value = authz.get(source_name) source_value = authz.get(source_name)
if isinstance(source_value, str) and source_value and remote_record.get(target_name) != source_value: if not isinstance(source_value, str):
remote_record[target_name] = source_value 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 changed = True
return changed 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"] 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: if token_record is None:
token_record = { token_record = {
"token_id": token_id, "id": remote_name,
"value": token_value, "value": token_value,
"server": server_record_from_info(server_info), "server": server_record_from_info(server_info),
"remotes": [], "user": user_name,
"org": str(server_info.get("org", "")),
"repo": str(server_info.get("repo", "")),
} }
token_data.setdefault("tokens", []).append(token_record) token_data.setdefault("tokens", []).append(token_record)
result = "added" result = "added"
else:
token_record["token_id"] = token_id
result = "updated"
else: else:
result = "existing" result = "existing"
if token_record.get("value") != token_value: if token_record.get("value") != token_value:
token_record["value"] = token_value token_record["value"] = token_value
result = "updated" 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): if token_record.get("server") != server_record_from_info(server_info):
token_record["server"] = server_record_from_info(server_info) token_record["server"] = server_record_from_info(server_info)
result = "updated" result = "updated"
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
remote_record, remote_changed = upsert_remote_record(token_record, remote_name, server_info) source_value = server_info.get(source_name)
if remote_changed and result == "existing": if isinstance(source_value, str) and token_record.get(target_name) != source_value:
token_record[target_name] = source_value
result = "updated" result = "updated"
if hydrate:
authz = load_gitea_authz( authz = load_gitea_authz(
endpoint, endpoint,
token_id, remote_name,
token_value, token_value,
token_record.get("expires_at", ""), token_record.get("expires_at", ""),
remote_record.get("org", ""), token_record.get("org", ""),
remote_record.get("repo", ""), token_record.get("repo", ""),
) )
if apply_authz_to_token(token_record, remote_record, authz) and result == "existing": if apply_authz_to_token(token_record, authz) and result == "existing":
result = "updated" result = "updated"
return result return result
@@ -804,7 +937,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict:
continue continue
auth_urls += 1 auth_urls += 1
token_id, token_value = credentials user_name, token_value = credentials
row_index = len(remote_rows) row_index = len(remote_rows)
remote_rows.append( remote_rows.append(
{ {
@@ -814,12 +947,13 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict:
"org": server_info["org"], "org": server_info["org"],
"repo": server_info["repo"], "repo": server_info["repo"],
"url_kind": "auth", "url_kind": "auth",
"token_id": token_id, "token_id": remote_name,
"user": user_name,
"token_value": token_value, "token_value": token_value,
"result": "found", "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 { return {
"repo_root": repo_root, "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) token_data = load_token_store(config)
added = 0 added = 0
updated = 0 updated = 0
for row_index, server_info, remote_name, token_id, token_value in found_credentials: for row_index, server_info, remote_name, user_name, token_value in found_credentials:
result = register_token(token_data, server_info, remote_name, token_id, token_value) result = register_token(token_data, server_info, remote_name, user_name, token_value)
if result == "added": if result == "added":
added += 1 added += 1
elif result == "updated": elif result == "updated":
@@ -866,6 +1000,67 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
return report 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: def count_server_tokens(server_entry: dict) -> int:
return len(server_entry.get("tokens", [])) return len(server_entry.get("tokens", []))
@@ -900,26 +1095,23 @@ 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}: 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) server_entry[field_name] = server_record.get(field_name)
remotes = token_record.get("remotes", []) token_id = token_record_id(token_record)
if not isinstance(remotes, list) or not remotes: if not token_id:
remotes = [{}]
for remote_record in remotes:
if not isinstance(remote_record, dict):
continue continue
server_entry["tokens"].append( server_entry["tokens"].append(
{ {
"endpoint": endpoint, "endpoint": endpoint,
"token_id": str(token_record.get("token_id", "")), "token_id": token_id,
"token_value": str(token_record.get("value", "")), "token_value": str(token_record.get("value", "")),
"user": str(token_record.get("user", "")), "user": str(token_record.get("user", "")),
"valid": str(token_record.get("valid", "")), "valid": str(token_record.get("valid", "")),
"scope_mask": str(token_record.get("scope", "")), "scope_mask": permission_map_to_mask(token_record.get("scope"), SCOPE_FIELDS, "rw?!-"),
"expires_at": str(token_record.get("expires_at", "")), "expires_at": str(token_record.get("expires_at", "")),
"remote": str(remote_record.get("name", "")), "remote": token_id,
"owner": str(remote_record.get("org", "")), "owner": str(token_record.get("org", "")),
"repo_name": str(remote_record.get("repo", "")), "repo_name": str(token_record.get("repo", "")),
"org_mask": str(remote_record.get("org_perm", "")), "org_mask": permission_map_to_mask(token_record.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-"),
"repo_mask": str(remote_record.get("repo_perm", "")), "repo_mask": permission_map_to_mask(token_record.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-"),
} }
) )
return servers return servers
@@ -938,7 +1130,7 @@ def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str,
token_pairs.add( token_pairs.add(
( (
str(row.get("remote", "")), str(row.get("remote", "")),
str(token_id), str(row.get("user", "")),
str(token_value), str(token_value),
str(row.get("org", row.get("owner", ""))), str(row.get("org", row.get("owner", ""))),
str(row.get("repo", row.get("repo_name", ""))), str(row.get("repo", row.get("repo_name", ""))),
@@ -997,13 +1189,14 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup
continue continue
entry["auth_urls"] += 1 entry["auth_urls"] += 1
token_id, token_value = credentials user_name, token_value = credentials
entry["tokens"].append( entry["tokens"].append(
{ {
"remote": remote_name, "remote": remote_name,
"org": server_info["org"], "org": server_info["org"],
"repo": server_info["repo"], "repo": server_info["repo"],
"token_id": token_id, "token_id": remote_name,
"user": user_name,
"token_value": token_value, "token_value": token_value,
} }
) )
@@ -1189,6 +1382,7 @@ def repo_token_rows(
"owner": row.get("org", ""), "owner": row.get("org", ""),
"repo_name": row.get("repo", ""), "repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""), "token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""), "token_value": row.get("token_value", ""),
} }
for row in scan_rows for row in scan_rows
@@ -1208,6 +1402,7 @@ def repo_token_rows(
"owner": row.get("org", ""), "owner": row.get("org", ""),
"repo_name": row.get("repo", ""), "repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""), "token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""), "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: def scope_mask(scopes: list[str]) -> str:
scope_set = {scope.lower() for scope in scopes} scope_set = {scope.lower() for scope in scopes}
categories = [
"activitypub",
"admin",
"issue",
"misc",
"notification",
"organization",
"package",
"repository",
"user",
]
values = [] values = []
for category in categories: for _, category in SCOPE_FIELDS:
if f"write:{category}" in scope_set: if f"write:{category}" in scope_set:
values.append("w") values.append("w")
elif f"read:{category}" in scope_set: elif f"read:{category}" in scope_set:
@@ -1448,6 +1632,7 @@ def print_token_item_rows(
if store_row: if store_row:
repo_fields_match = ( repo_fields_match = (
store_row["token_id"] == row["token_id"] store_row["token_id"] == row["token_id"]
and store_row["user"] == row.get("user", "")
and store_row["token_value"] == token_value and store_row["token_value"] == token_value
and store_row["owner"] == row["owner"] and store_row["owner"] == row["owner"]
and store_row["repo_name"] == row["repo_name"] 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 "*" marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*"
append_token_row( append_token_row(
endpoint, 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"], row["remote"],
token_id, token_id,
token_value, token_value,
@@ -1491,7 +1676,7 @@ def print_token_item_rows(
def resolve_token_from_store( def resolve_token_from_store(
token_data: dict, token_data: dict,
endpoint: str | None, endpoint: str | None,
token_id: str | None, remote_id: str | None,
user_name: str | None, user_name: str | None,
) -> dict: ) -> dict:
candidates = [] candidates = []
@@ -1499,7 +1684,7 @@ def resolve_token_from_store(
token_endpoint = token_server_endpoint(token_record) token_endpoint = token_server_endpoint(token_record)
if endpoint and token_endpoint != endpoint: if endpoint and token_endpoint != endpoint:
continue continue
if token_id and token_record.get("token_id") != token_id: if remote_id and token_record_id(token_record) != remote_id:
continue continue
if user_name and token_record.get("user") != user_name: if user_name and token_record.get("user") != user_name:
continue continue
@@ -1510,11 +1695,11 @@ def resolve_token_from_store(
if len(candidates) > 1: if len(candidates) > 1:
available = ", ".join( available = ", ".join(
sorted( 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 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] 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"host\t{server_entry.get('host', '')}")
print(f"port\t{server_entry.get('port', '')}") print(f"port\t{server_entry.get('port', '')}")
print(f"tokens\t{count_server_tokens(server_entry)}") 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", "") token_value = row.get("token_value", "")
secret_value = token_value if args.show_secrets else mask_secret(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"]: for field_name in ["user", "valid", "scope_mask", "expires_at"]:
field_value = row.get(field_name, "") field_value = row.get(field_name, "")
if field_value: if field_value:
@@ -1622,6 +1807,26 @@ def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None:
print_status_counts(status_counts) 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: def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None:
server_url = (args.server or config.git.base_url).rstrip("/") server_url = (args.server or config.git.base_url).rstrip("/")
server_info = server_info_from_url(config, server_url) 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}") raise SystemExit(f"Only http/https server URLs are supported: {server_url}")
token_data = load_token_store(config) 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: 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_record = {
"token_id": args.token_id, "id": remote_id,
"value": args.value or "", "value": args.value or "",
"server": server_record_from_info(server_info), "server": server_record_from_info(server_info),
"remotes": [],
}
if args.user:
token_record["user"] = args.user
if args.remote:
token_record["remotes"].append(
{
"name": args.remote,
"org": args.org or "", "org": args.org or "",
"repo": args.repo_name or "", "repo": args.repo_name or "",
} }
) if args.user:
token_record["user"] = args.user
print(f"token_path\t{config.token_path}") print(f"token_path\t{config.token_path}")
print(f"server\t{server_info['endpoint']}") 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"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'}") print(f"status\t{'dry-run' if args.dry_run else 'added'}")
if args.dry_run: 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_record = resolve_token_from_store(
token_data, token_data,
args.server or target_server_info["endpoint"], args.server or target_server_info["endpoint"],
args.token_name, args.token_name or args.remote,
args.user, args.user,
) )
server_endpoint = token_server_endpoint(token_record) 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", "")) token_value = str(token_record.get("value", ""))
if not token_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" status = "added" if existing_remote_url is None else "updated"
existing_credentials = remote_credentials(existing_remote_url) if existing_remote_url else None 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" status = "unchanged"
elif existing_credentials is not None and not args.replace: elif existing_credentials is not None and not args.replace:
raise SystemExit( 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"repo_root\t{repo_root}")
print(f"remote\t{args.remote}") print(f"remote\t{args.remote}")
print(f"server\t{server_endpoint}") print(f"server\t{server_endpoint}")
print(f"user\t{token_record.get('user', '')}") print(f"user\t{user_name}")
print(f"token_name\t{token_id}") print(f"id\t{remote_id}")
print(f"status\t{status}") print(f"status\t{status}")
print(f"url\t{sanitize_remote_url(final_url)[0]}") 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: 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.from_source == "remotes":
if args.dry_run: if args.dry_run:
run_tokens_scan(config, args) run_tokens_scan(config, args)
@@ -1736,7 +1946,7 @@ def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None
if args.from_source == "store": if args.from_source == "store":
run_tokens_write(config, args) run_tokens_write(config, args)
return 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: 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: def add_store_selection_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--server", help="Server endpoint from tokens.json.") parser.add_argument("--server", help="Server endpoint from tokens.json.")
parser.add_argument("--user", help="API user recorded on the token.") 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: 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"], ["list-cards", "[series]", "list cards in a selected series"],
["tmux-container", "[series] [card]", "start tmux with container in pane 0"], ["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
["submission", "[series] [card] --class K --nick N", "prepare answer repo and student branch"], ["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() print()
@@ -2054,11 +2264,13 @@ def print_tokens_overview() -> None:
["command", "common options", "direction", "purpose"], ["command", "common options", "direction", "purpose"],
[ [
["scan", "[--repo PATH]", "read-only", "print token table with remote/store marker and auth masks"], ["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"], ["add", "REMOTE_ID", "tokens.json", "add an empty token skeleton for manual editing"],
["read", "[--server ENDPOINT]", "tokens.json", "show servers, token ids and remotes"], ["read", "[--server ENDPOINT]", "tokens.json", "show servers, remote ids and tokens"],
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"], ["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"], ["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() print()
@@ -2067,9 +2279,12 @@ def print_tokens_overview() -> None:
["case", "command"], ["case", "command"],
[ [
["scan current repo", "./rvctl tokens scan"], ["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"], ["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"], ["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", "add",
help="Add an empty token skeleton to tokens.json.", 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("--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("--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("--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("--org", help="Optional remote organization.")
tokens_add_parser.add_argument("--repo", dest="repo_name", help="Optional remote repository.") 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.") 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) add_repo_option(tokens_stats_parser)
tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.") 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( tokens_write_parser = tokens_subparsers.add_parser(
"write", "write",
help="Write credentials from tokens.json into a repo remote URL.", 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( tokens_update_parser = tokens_subparsers.add_parser(
"update", "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( tokens_update_parser.add_argument(
"--from", "--from",
dest="from_source", dest="from_source",
required=True,
choices=["remotes", "store"], choices=["remotes", "store"],
help="Synchronization direction source: 'remotes' or 'store'.", help="Legacy synchronization direction source: 'remotes' or 'store'.",
) )
add_repo_option(tokens_update_parser) add_repo_option(tokens_update_parser)
tokens_update_parser.add_argument("--remote", help="Remote name used when --from store.") 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": if args.tokens_command == "stats":
run_tokens_stats(config, args) run_tokens_stats(config, args)
return 0 return 0
if args.tokens_command == "sync":
run_tokens_sync(config, args)
return 0
if args.tokens_command == "write": if args.tokens_command == "write":
run_tokens_write(config, args) run_tokens_write(config, args)
return 0 return 0