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
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,16 +125,12 @@ Minimalny format:
"host": "77.90.8.171",
"port": 3001
},
"remotes": [
{
"name": "r1",
"user": "u1",
"org": "edu-tools",
"repo": "rv-launcher"
}
]
}
]
}
```
Pelny schemat pliku jest w `doc/tokens.schema.json`.
@@ -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`.
+95 -33
View File
@@ -103,22 +103,23 @@ Minimalny format pliku:
```json
{
"version": 2,
"servers": {
"http://77.90.8.171:3001": {
"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,
"users": {
"u1": {
"tokens": {
"t1": "TU_WSTAW_TOKEN"
}
}
}
}
"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 @@ type<TAB>gitea
scheme<TAB>http
host<TAB>77.90.8.171
port<TAB>3001
users<TAB>1
tokens<TAB>1
user<TAB>u1
token<TAB>t1<TAB>SE****23
id<TAB>r1<TAB>SE****23
user<TAB>r1<TAB>u1
```
Przyklad:
@@ -415,7 +459,7 @@ token_path<TAB>...
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
item<TAB>value
@@ -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]`
+104 -62
View File
@@ -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",
"valid": "forever",
"scope": "-----w---",
"remotes": [
{
"name": "r1",
"org": "edu-tools",
"repo": "rv-launcher",
"org_perm": "+++++",
"repo_perm": "++++"
"valid": "forever",
"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
```
+123 -41
View File
@@ -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"
}
}
}
+402 -172
View File
@@ -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,110 +740,98 @@ 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)
if token_record is None:
token_record = find_orphan_token_by_value(token_data, endpoint, token_value)
token_record = find_token_record(token_data, endpoint, remote_name)
if token_record is None:
token_record = {
"token_id": token_id,
"id": remote_name,
"value": token_value,
"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)
result = "added"
else:
token_record["token_id"] = token_id
result = "updated"
else:
result = "existing"
if token_record.get("value") != token_value:
token_record["value"] = token_value
result = "updated"
if token_record.get("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"
remote_record, remote_changed = upsert_remote_record(token_record, remote_name, server_info)
if remote_changed and result == "existing":
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"
if hydrate:
authz = load_gitea_authz(
endpoint,
token_id,
remote_name,
token_value,
token_record.get("expires_at", ""),
remote_record.get("org", ""),
remote_record.get("repo", ""),
token_record.get("org", ""),
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"
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,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}:
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):
token_id = token_record_id(token_record)
if not token_id:
continue
server_entry["tokens"].append(
{
"endpoint": endpoint,
"token_id": str(token_record.get("token_id", "")),
"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": 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", "")),
"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", "")),
"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": [],
}
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 "",
}
)
if args.user:
token_record["user"] = args.user
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