Restructure token store by remote id
This commit is contained in:
@@ -53,6 +53,34 @@ class WorkspaceConfig:
|
||||
git: GitConfig
|
||||
|
||||
|
||||
SCOPE_FIELDS = [
|
||||
("a", "activitypub"),
|
||||
("A", "admin"),
|
||||
("i", "issue"),
|
||||
("m", "misc"),
|
||||
("n", "notification"),
|
||||
("o", "organization"),
|
||||
("p", "package"),
|
||||
("r", "repository"),
|
||||
("u", "user"),
|
||||
]
|
||||
|
||||
ORG_PERMISSION_FIELDS = [
|
||||
("o", "owner"),
|
||||
("a", "admin"),
|
||||
("w", "write"),
|
||||
("r", "read"),
|
||||
("c", "create"),
|
||||
]
|
||||
|
||||
REPO_PERMISSION_FIELDS = [
|
||||
("o", "owner"),
|
||||
("a", "admin"),
|
||||
("w", "write"),
|
||||
("r", "read"),
|
||||
]
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> WorkspaceConfig:
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
base_dir = config_path.parent
|
||||
@@ -381,10 +409,17 @@ def normalize_remote_record(raw_remote: dict) -> dict | None:
|
||||
return None
|
||||
|
||||
remote_record = {"name": remote_name, "org": "", "repo": ""}
|
||||
for field_name in ["org", "repo", "org_perm", "repo_perm"]:
|
||||
for field_name in ["org", "repo"]:
|
||||
field_value = raw_remote.get(field_name)
|
||||
if isinstance(field_value, str) and field_value:
|
||||
remote_record[field_name] = field_value
|
||||
|
||||
org_perm = normalize_permission_map(raw_remote.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
|
||||
if org_perm is not None:
|
||||
remote_record["org_perm"] = org_perm
|
||||
repo_perm = normalize_permission_map(raw_remote.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
|
||||
if repo_perm is not None:
|
||||
remote_record["repo_perm"] = repo_perm
|
||||
return remote_record
|
||||
|
||||
|
||||
@@ -392,36 +427,128 @@ def valid_mask(raw_value: str, allowed: str, width: int) -> bool:
|
||||
return len(raw_value) == width and all(char in allowed for char in raw_value)
|
||||
|
||||
|
||||
def normalize_v3_token(config: WorkspaceConfig, raw_token: dict) -> dict | None:
|
||||
if not isinstance(raw_token, dict):
|
||||
def permission_map_from_mask(
|
||||
raw_value: str,
|
||||
fields: list[tuple[str, str]],
|
||||
allowed: str,
|
||||
) -> dict[str, str] | None:
|
||||
if not valid_mask(raw_value, allowed, len(fields)):
|
||||
return None
|
||||
return {key: value for (key, _), value in zip(fields, raw_value)}
|
||||
|
||||
|
||||
def normalize_permission_map(
|
||||
raw_value,
|
||||
fields: list[tuple[str, str]],
|
||||
allowed: str,
|
||||
unknown: str = "?",
|
||||
) -> dict[str, str] | None:
|
||||
if isinstance(raw_value, str):
|
||||
return permission_map_from_mask(raw_value, fields, allowed)
|
||||
if not isinstance(raw_value, dict):
|
||||
return None
|
||||
|
||||
token_id = raw_token.get("token_id") or raw_token.get("id") or raw_token.get("token_ref")
|
||||
token_value = raw_token.get("value")
|
||||
if token_value is None:
|
||||
token_value = raw_token.get("token")
|
||||
if not isinstance(token_id, str) or not token_id:
|
||||
return None
|
||||
if not isinstance(token_value, str):
|
||||
return None
|
||||
normalized: dict[str, str] = {}
|
||||
found = False
|
||||
for key, long_name in fields:
|
||||
field_value = raw_value.get(key, raw_value.get(long_name))
|
||||
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
|
||||
normalized[key] = field_value
|
||||
found = True
|
||||
else:
|
||||
normalized[key] = unknown
|
||||
return normalized if found else None
|
||||
|
||||
|
||||
def permission_map_to_mask(
|
||||
raw_value,
|
||||
fields: list[tuple[str, str]],
|
||||
allowed: str,
|
||||
unknown: str = "?",
|
||||
) -> str:
|
||||
if isinstance(raw_value, str) and valid_mask(raw_value, allowed, len(fields)):
|
||||
return raw_value
|
||||
if not isinstance(raw_value, dict):
|
||||
return ""
|
||||
|
||||
values = []
|
||||
for key, long_name in fields:
|
||||
field_value = raw_value.get(key, raw_value.get(long_name))
|
||||
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
|
||||
values.append(field_value)
|
||||
else:
|
||||
values.append(unknown)
|
||||
return "".join(values)
|
||||
|
||||
|
||||
def normalize_flat_token(
|
||||
config: WorkspaceConfig,
|
||||
raw_token: dict,
|
||||
remote_id: str,
|
||||
token_value: str,
|
||||
user_name: str,
|
||||
org_name: str,
|
||||
repo_name: str,
|
||||
org_perm=None,
|
||||
repo_perm=None,
|
||||
) -> dict | None:
|
||||
server_record = normalize_server_record(config, raw_token.get("server", {}))
|
||||
if server_record is None:
|
||||
return None
|
||||
|
||||
token_record = {
|
||||
"token_id": token_id,
|
||||
"id": remote_id,
|
||||
"value": token_value,
|
||||
"server": server_record,
|
||||
"remotes": [],
|
||||
"org": org_name,
|
||||
"repo": repo_name,
|
||||
}
|
||||
for field_name in ["user", "valid", "expires_at"]:
|
||||
if user_name:
|
||||
token_record["user"] = user_name
|
||||
for field_name in ["valid", "expires_at"]:
|
||||
field_value = raw_token.get(field_name)
|
||||
if isinstance(field_value, str) and field_value:
|
||||
token_record[field_name] = field_value
|
||||
scope_value = raw_token.get("scope")
|
||||
if isinstance(scope_value, str) and valid_mask(scope_value, "rw?!-", 9):
|
||||
token_record["scope"] = scope_value
|
||||
|
||||
scope_record = normalize_permission_map(raw_token.get("scope"), SCOPE_FIELDS, "rw?!-")
|
||||
if scope_record is not None:
|
||||
token_record["scope"] = scope_record
|
||||
|
||||
org_record = normalize_permission_map(org_perm if org_perm is not None else raw_token.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
|
||||
if org_record is not None:
|
||||
token_record["org_perm"] = org_record
|
||||
repo_record = normalize_permission_map(repo_perm if repo_perm is not None else raw_token.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
|
||||
if repo_record is not None:
|
||||
token_record["repo_perm"] = repo_record
|
||||
return token_record
|
||||
|
||||
|
||||
def normalize_v3_tokens(config: WorkspaceConfig, raw_token: dict) -> list[dict]:
|
||||
if not isinstance(raw_token, dict):
|
||||
return []
|
||||
|
||||
remote_id = raw_token.get("id") or raw_token.get("remote")
|
||||
legacy_token_id = raw_token.get("token_id") or raw_token.get("token_ref")
|
||||
token_value = raw_token.get("value")
|
||||
if token_value is None:
|
||||
token_value = raw_token.get("token")
|
||||
if not isinstance(token_value, str):
|
||||
return []
|
||||
user_name = raw_token.get("user")
|
||||
if not isinstance(user_name, str):
|
||||
user_name = ""
|
||||
|
||||
if isinstance(remote_id, str) and remote_id:
|
||||
token_record = normalize_flat_token(
|
||||
config,
|
||||
raw_token,
|
||||
remote_id,
|
||||
token_value,
|
||||
user_name,
|
||||
str(raw_token.get("org", "")),
|
||||
str(raw_token.get("repo", "")),
|
||||
)
|
||||
return [token_record] if token_record is not None else []
|
||||
|
||||
raw_remotes = raw_token.get("remotes", [])
|
||||
if isinstance(raw_remotes, dict):
|
||||
@@ -430,34 +557,60 @@ def normalize_v3_token(config: WorkspaceConfig, raw_token: dict) -> dict | None:
|
||||
for remote_name, remote_value in raw_remotes.items()
|
||||
if isinstance(remote_value, dict)
|
||||
]
|
||||
if not isinstance(raw_remotes, list):
|
||||
raw_remotes = []
|
||||
|
||||
normalized_records: list[dict] = []
|
||||
if isinstance(raw_remotes, list):
|
||||
for raw_remote in raw_remotes:
|
||||
remote_record = normalize_remote_record(raw_remote)
|
||||
if remote_record is not None:
|
||||
token_record["remotes"].append(remote_record)
|
||||
if remote_record is None:
|
||||
continue
|
||||
legacy_user_name = user_name
|
||||
if not legacy_user_name and isinstance(legacy_token_id, str):
|
||||
legacy_user_name = legacy_token_id
|
||||
token_record = normalize_flat_token(
|
||||
config,
|
||||
raw_token,
|
||||
remote_record["name"],
|
||||
token_value,
|
||||
legacy_user_name,
|
||||
remote_record.get("org", ""),
|
||||
remote_record.get("repo", ""),
|
||||
remote_record.get("org_perm"),
|
||||
remote_record.get("repo_perm"),
|
||||
)
|
||||
if token_record is not None:
|
||||
normalized_records.append(token_record)
|
||||
|
||||
return token_record
|
||||
if not normalized_records and isinstance(legacy_token_id, str) and legacy_token_id:
|
||||
token_record = normalize_flat_token(
|
||||
config,
|
||||
raw_token,
|
||||
legacy_token_id,
|
||||
token_value,
|
||||
user_name,
|
||||
str(raw_token.get("org", "")),
|
||||
str(raw_token.get("repo", "")),
|
||||
)
|
||||
if token_record is not None:
|
||||
normalized_records.append(token_record)
|
||||
return normalized_records
|
||||
|
||||
|
||||
def append_token_record(token_data: dict, token_record: dict) -> None:
|
||||
tokens = token_data.setdefault("tokens", [])
|
||||
token_key = (
|
||||
token_record.get("server", {}).get("endpoint", ""),
|
||||
token_record.get("token_id", ""),
|
||||
token_record.get("value", ""),
|
||||
token_record.get("id", ""),
|
||||
)
|
||||
for existing_record in tokens:
|
||||
existing_key = (
|
||||
existing_record.get("server", {}).get("endpoint", ""),
|
||||
existing_record.get("token_id", ""),
|
||||
existing_record.get("value", ""),
|
||||
existing_record.get("id", ""),
|
||||
)
|
||||
if existing_key == token_key:
|
||||
existing_remotes = existing_record.setdefault("remotes", [])
|
||||
existing_remote_names = {remote.get("name", "") for remote in existing_remotes}
|
||||
for remote_record in token_record.get("remotes", []):
|
||||
if remote_record.get("name", "") not in existing_remote_names:
|
||||
existing_remotes.append(remote_record)
|
||||
existing_record.update(token_record)
|
||||
return
|
||||
tokens.append(token_record)
|
||||
|
||||
@@ -504,27 +657,20 @@ def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> d
|
||||
if token_entry is None:
|
||||
continue
|
||||
token_value = token_entry_value(token_entry)
|
||||
remote_name = token_entry_field(token_entry, "remote") or str(token_name)
|
||||
|
||||
token_record = {
|
||||
"token_id": str(token_name),
|
||||
"id": remote_name,
|
||||
"value": token_value,
|
||||
"server": server_record_from_info(server_info),
|
||||
"user": str(user_name),
|
||||
"remotes": [],
|
||||
"org": token_entry_field(token_entry, "org"),
|
||||
"repo": token_entry_field(token_entry, "repo"),
|
||||
}
|
||||
expires_at = token_entry_expires_at(token_entry)
|
||||
if expires_at:
|
||||
token_record["expires_at"] = expires_at
|
||||
|
||||
remote_name = token_entry_field(token_entry, "remote")
|
||||
if remote_name:
|
||||
remote_record = {"name": remote_name, "org": "", "repo": ""}
|
||||
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
|
||||
source_value = token_entry_field(token_entry, source_name)
|
||||
if source_value:
|
||||
remote_record[target_name] = source_value
|
||||
token_record["remotes"].append(remote_record)
|
||||
|
||||
append_token_record(token_data, token_record)
|
||||
|
||||
return token_data
|
||||
@@ -534,8 +680,7 @@ def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict:
|
||||
if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list):
|
||||
token_data = empty_token_store()
|
||||
for raw_token in raw_data["tokens"]:
|
||||
token_record = normalize_v3_token(config, raw_token)
|
||||
if token_record is not None:
|
||||
for token_record in normalize_v3_tokens(config, raw_token):
|
||||
append_token_record(token_data, token_record)
|
||||
return token_data
|
||||
|
||||
@@ -582,7 +727,7 @@ def write_token_store(config: WorkspaceConfig, token_data: dict) -> None:
|
||||
os.chmod(config.token_path.parent, 0o700)
|
||||
|
||||
temp_path = config.token_path.with_suffix(config.token_path.suffix + ".tmp")
|
||||
temp_path.write_text(json.dumps(token_data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temp_path.write_text(json.dumps(token_data, indent=2) + "\n", encoding="utf-8")
|
||||
os.chmod(temp_path, 0o600)
|
||||
temp_path.replace(config.token_path)
|
||||
os.chmod(config.token_path, 0o600)
|
||||
@@ -595,111 +740,99 @@ def token_server_endpoint(token_record: dict) -> str:
|
||||
return str(server_record.get("endpoint", ""))
|
||||
|
||||
|
||||
def find_token_record(token_data: dict, endpoint: str, token_id: str) -> dict | None:
|
||||
def token_record_id(token_record: dict) -> str:
|
||||
return str(token_record.get("id", ""))
|
||||
|
||||
|
||||
def find_token_record(token_data: dict, endpoint: str, remote_id: str) -> dict | None:
|
||||
for token_record in token_data.get("tokens", []):
|
||||
if token_server_endpoint(token_record) == endpoint and token_record.get("token_id") == token_id:
|
||||
if token_server_endpoint(token_record) == endpoint and token_record_id(token_record) == remote_id:
|
||||
return token_record
|
||||
return None
|
||||
|
||||
|
||||
def find_orphan_token_by_value(token_data: dict, endpoint: str, token_value: str) -> dict | None:
|
||||
for token_record in token_data.get("tokens", []):
|
||||
if token_server_endpoint(token_record) != endpoint:
|
||||
continue
|
||||
if token_record.get("value") != token_value:
|
||||
continue
|
||||
if token_record.get("remotes"):
|
||||
continue
|
||||
return token_record
|
||||
return None
|
||||
|
||||
|
||||
def find_remote_record(token_record: dict, remote_name: str) -> dict | None:
|
||||
for remote_record in token_record.setdefault("remotes", []):
|
||||
if remote_record.get("name") == remote_name:
|
||||
return remote_record
|
||||
return None
|
||||
|
||||
|
||||
def upsert_remote_record(token_record: dict, remote_name: str, server_info: dict) -> tuple[dict, bool]:
|
||||
remote_record = find_remote_record(token_record, remote_name)
|
||||
changed = False
|
||||
if remote_record is None:
|
||||
remote_record = {"name": remote_name, "org": "", "repo": ""}
|
||||
token_record.setdefault("remotes", []).append(remote_record)
|
||||
changed = True
|
||||
|
||||
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
|
||||
source_value = server_info.get(source_name)
|
||||
if isinstance(source_value, str) and source_value and remote_record.get(target_name) != source_value:
|
||||
remote_record[target_name] = source_value
|
||||
changed = True
|
||||
return remote_record, changed
|
||||
|
||||
|
||||
def apply_authz_to_token(token_record: dict, remote_record: dict, authz: dict) -> bool:
|
||||
def apply_authz_to_token(token_record: dict, authz: dict) -> bool:
|
||||
changed = False
|
||||
for source_name, target_name in [
|
||||
("user", "user"),
|
||||
("valid", "valid"),
|
||||
("scope_mask", "scope"),
|
||||
]:
|
||||
source_value = authz.get(source_name)
|
||||
if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value:
|
||||
token_record[target_name] = source_value
|
||||
changed = True
|
||||
|
||||
scope_value = authz.get("scope_mask")
|
||||
if isinstance(scope_value, str):
|
||||
scope_record = permission_map_from_mask(scope_value, SCOPE_FIELDS, "rw?!-")
|
||||
if scope_record is not None and token_record.get("scope") != scope_record:
|
||||
token_record["scope"] = scope_record
|
||||
changed = True
|
||||
|
||||
for source_name, target_name in [
|
||||
("org_mask", "org_perm"),
|
||||
("repo_mask", "repo_perm"),
|
||||
]:
|
||||
source_value = authz.get(source_name)
|
||||
if isinstance(source_value, str) and source_value and remote_record.get(target_name) != source_value:
|
||||
remote_record[target_name] = source_value
|
||||
if not isinstance(source_value, str):
|
||||
continue
|
||||
fields = ORG_PERMISSION_FIELDS if target_name == "org_perm" else REPO_PERMISSION_FIELDS
|
||||
permission_record = permission_map_from_mask(source_value, fields, "+?!-")
|
||||
if permission_record is not None and token_record.get(target_name) != permission_record:
|
||||
token_record[target_name] = permission_record
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def register_token(token_data: dict, server_info: dict, remote_name: str, token_id: str, token_value: str) -> str:
|
||||
def register_token(
|
||||
token_data: dict,
|
||||
server_info: dict,
|
||||
remote_name: str,
|
||||
user_name: str,
|
||||
token_value: str,
|
||||
hydrate: bool = True,
|
||||
) -> str:
|
||||
endpoint = server_info["endpoint"]
|
||||
token_record = find_token_record(token_data, endpoint, token_id)
|
||||
token_record = find_token_record(token_data, endpoint, remote_name)
|
||||
if token_record is None:
|
||||
token_record = find_orphan_token_by_value(token_data, endpoint, token_value)
|
||||
if token_record is None:
|
||||
token_record = {
|
||||
"token_id": token_id,
|
||||
"value": token_value,
|
||||
"server": server_record_from_info(server_info),
|
||||
"remotes": [],
|
||||
}
|
||||
token_data.setdefault("tokens", []).append(token_record)
|
||||
result = "added"
|
||||
else:
|
||||
token_record["token_id"] = token_id
|
||||
result = "updated"
|
||||
token_record = {
|
||||
"id": remote_name,
|
||||
"value": token_value,
|
||||
"server": server_record_from_info(server_info),
|
||||
"user": user_name,
|
||||
"org": str(server_info.get("org", "")),
|
||||
"repo": str(server_info.get("repo", "")),
|
||||
}
|
||||
token_data.setdefault("tokens", []).append(token_record)
|
||||
result = "added"
|
||||
else:
|
||||
result = "existing"
|
||||
if token_record.get("value") != token_value:
|
||||
token_record["value"] = token_value
|
||||
result = "updated"
|
||||
if token_record.get("user") != user_name:
|
||||
token_record["user"] = user_name
|
||||
result = "updated"
|
||||
if token_record.get("server") != server_record_from_info(server_info):
|
||||
token_record["server"] = server_record_from_info(server_info)
|
||||
result = "updated"
|
||||
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
|
||||
source_value = server_info.get(source_name)
|
||||
if isinstance(source_value, str) and token_record.get(target_name) != source_value:
|
||||
token_record[target_name] = source_value
|
||||
result = "updated"
|
||||
|
||||
remote_record, remote_changed = upsert_remote_record(token_record, remote_name, server_info)
|
||||
if remote_changed and result == "existing":
|
||||
result = "updated"
|
||||
|
||||
authz = load_gitea_authz(
|
||||
endpoint,
|
||||
token_id,
|
||||
token_value,
|
||||
token_record.get("expires_at", ""),
|
||||
remote_record.get("org", ""),
|
||||
remote_record.get("repo", ""),
|
||||
)
|
||||
if apply_authz_to_token(token_record, remote_record, authz) and result == "existing":
|
||||
result = "updated"
|
||||
if hydrate:
|
||||
authz = load_gitea_authz(
|
||||
endpoint,
|
||||
remote_name,
|
||||
token_value,
|
||||
token_record.get("expires_at", ""),
|
||||
token_record.get("org", ""),
|
||||
token_record.get("repo", ""),
|
||||
)
|
||||
if apply_authz_to_token(token_record, authz) and result == "existing":
|
||||
result = "updated"
|
||||
return result
|
||||
|
||||
|
||||
@@ -804,7 +937,7 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict:
|
||||
continue
|
||||
|
||||
auth_urls += 1
|
||||
token_id, token_value = credentials
|
||||
user_name, token_value = credentials
|
||||
row_index = len(remote_rows)
|
||||
remote_rows.append(
|
||||
{
|
||||
@@ -814,12 +947,13 @@ def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict:
|
||||
"org": server_info["org"],
|
||||
"repo": server_info["repo"],
|
||||
"url_kind": "auth",
|
||||
"token_id": token_id,
|
||||
"token_id": remote_name,
|
||||
"user": user_name,
|
||||
"token_value": token_value,
|
||||
"result": "found",
|
||||
}
|
||||
)
|
||||
found_credentials.append((row_index, server_info, remote_name, token_id, token_value))
|
||||
found_credentials.append((row_index, server_info, remote_name, user_name, token_value))
|
||||
|
||||
return {
|
||||
"repo_root": repo_root,
|
||||
@@ -848,8 +982,8 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
|
||||
token_data = load_token_store(config)
|
||||
added = 0
|
||||
updated = 0
|
||||
for row_index, server_info, remote_name, token_id, token_value in found_credentials:
|
||||
result = register_token(token_data, server_info, remote_name, token_id, token_value)
|
||||
for row_index, server_info, remote_name, user_name, token_value in found_credentials:
|
||||
result = register_token(token_data, server_info, remote_name, user_name, token_value)
|
||||
if result == "added":
|
||||
added += 1
|
||||
elif result == "updated":
|
||||
@@ -866,6 +1000,67 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
|
||||
return report
|
||||
|
||||
|
||||
def sync_token_from_remote(config: WorkspaceConfig, repo_path: Path, remote_name: str, dry_run: bool = False) -> dict:
|
||||
repo_root = git_repo_root(repo_path)
|
||||
if repo_root is None:
|
||||
raise SystemExit(f"Missing git repo at path: {repo_path}")
|
||||
|
||||
remote_url = git_capture(repo_root, ["remote", "get-url", remote_name])
|
||||
if not remote_url:
|
||||
raise SystemExit(f"Missing git remote: {remote_name}")
|
||||
server_info = server_info_from_url(config, remote_url)
|
||||
if server_info is None:
|
||||
raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}")
|
||||
credentials = remote_credentials(remote_url)
|
||||
if credentials is None:
|
||||
raise SystemExit(f"Remote '{remote_name}' has no credentials.")
|
||||
|
||||
user_name, token_value = credentials
|
||||
result = "dry-run"
|
||||
if not dry_run:
|
||||
token_data = load_token_store(config)
|
||||
result = register_token(token_data, server_info, remote_name, user_name, token_value, hydrate=False)
|
||||
write_token_store(config, token_data)
|
||||
|
||||
return {
|
||||
"repo_root": str(repo_root),
|
||||
"remote": remote_name,
|
||||
"server": server_info["endpoint"],
|
||||
"user": user_name,
|
||||
"org": server_info.get("org", ""),
|
||||
"repo": server_info.get("repo", ""),
|
||||
"status": result,
|
||||
}
|
||||
|
||||
|
||||
def hydrate_token_from_api(config: WorkspaceConfig, remote_name: str, endpoint: str | None, dry_run: bool = False) -> dict:
|
||||
token_data = load_token_store(config, write_normalized=not dry_run)
|
||||
token_record = resolve_token_from_store(token_data, endpoint, remote_name, None)
|
||||
token_value = str(token_record.get("value", ""))
|
||||
if not token_value:
|
||||
raise SystemExit(f"Token value is empty in tokens.json: {token_server_endpoint(token_record)}:{remote_name}")
|
||||
|
||||
authz = load_gitea_authz(
|
||||
token_server_endpoint(token_record),
|
||||
remote_name,
|
||||
token_value,
|
||||
token_record.get("expires_at", ""),
|
||||
token_record.get("org", ""),
|
||||
token_record.get("repo", ""),
|
||||
)
|
||||
changed = apply_authz_to_token(token_record, authz)
|
||||
if changed and not dry_run:
|
||||
write_token_store(config, token_data)
|
||||
|
||||
return {
|
||||
"remote": remote_name,
|
||||
"server": token_server_endpoint(token_record),
|
||||
"user": str(token_record.get("user", "")),
|
||||
"valid": str(token_record.get("valid", "")),
|
||||
"status": "dry-run" if dry_run else ("updated" if changed else "unchanged"),
|
||||
}
|
||||
|
||||
|
||||
def count_server_tokens(server_entry: dict) -> int:
|
||||
return len(server_entry.get("tokens", []))
|
||||
|
||||
@@ -900,28 +1095,25 @@ def store_servers_from_tokens(token_data: dict) -> dict[str, dict]:
|
||||
if server_entry.get(field_name) in {"", None, "unknown"} and server_record.get(field_name) not in {"", None}:
|
||||
server_entry[field_name] = server_record.get(field_name)
|
||||
|
||||
remotes = token_record.get("remotes", [])
|
||||
if not isinstance(remotes, list) or not remotes:
|
||||
remotes = [{}]
|
||||
for remote_record in remotes:
|
||||
if not isinstance(remote_record, dict):
|
||||
continue
|
||||
server_entry["tokens"].append(
|
||||
{
|
||||
"endpoint": endpoint,
|
||||
"token_id": str(token_record.get("token_id", "")),
|
||||
"token_value": str(token_record.get("value", "")),
|
||||
"user": str(token_record.get("user", "")),
|
||||
"valid": str(token_record.get("valid", "")),
|
||||
"scope_mask": str(token_record.get("scope", "")),
|
||||
"expires_at": str(token_record.get("expires_at", "")),
|
||||
"remote": str(remote_record.get("name", "")),
|
||||
"owner": str(remote_record.get("org", "")),
|
||||
"repo_name": str(remote_record.get("repo", "")),
|
||||
"org_mask": str(remote_record.get("org_perm", "")),
|
||||
"repo_mask": str(remote_record.get("repo_perm", "")),
|
||||
}
|
||||
)
|
||||
token_id = token_record_id(token_record)
|
||||
if not token_id:
|
||||
continue
|
||||
server_entry["tokens"].append(
|
||||
{
|
||||
"endpoint": endpoint,
|
||||
"token_id": token_id,
|
||||
"token_value": str(token_record.get("value", "")),
|
||||
"user": str(token_record.get("user", "")),
|
||||
"valid": str(token_record.get("valid", "")),
|
||||
"scope_mask": permission_map_to_mask(token_record.get("scope"), SCOPE_FIELDS, "rw?!-"),
|
||||
"expires_at": str(token_record.get("expires_at", "")),
|
||||
"remote": token_id,
|
||||
"owner": str(token_record.get("org", "")),
|
||||
"repo_name": str(token_record.get("repo", "")),
|
||||
"org_mask": permission_map_to_mask(token_record.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-"),
|
||||
"repo_mask": permission_map_to_mask(token_record.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-"),
|
||||
}
|
||||
)
|
||||
return servers
|
||||
|
||||
|
||||
@@ -938,7 +1130,7 @@ def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str,
|
||||
token_pairs.add(
|
||||
(
|
||||
str(row.get("remote", "")),
|
||||
str(token_id),
|
||||
str(row.get("user", "")),
|
||||
str(token_value),
|
||||
str(row.get("org", row.get("owner", ""))),
|
||||
str(row.get("repo", row.get("repo_name", ""))),
|
||||
@@ -997,13 +1189,14 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup
|
||||
continue
|
||||
|
||||
entry["auth_urls"] += 1
|
||||
token_id, token_value = credentials
|
||||
user_name, token_value = credentials
|
||||
entry["tokens"].append(
|
||||
{
|
||||
"remote": remote_name,
|
||||
"org": server_info["org"],
|
||||
"repo": server_info["repo"],
|
||||
"token_id": token_id,
|
||||
"token_id": remote_name,
|
||||
"user": user_name,
|
||||
"token_value": token_value,
|
||||
}
|
||||
)
|
||||
@@ -1189,6 +1382,7 @@ def repo_token_rows(
|
||||
"owner": row.get("org", ""),
|
||||
"repo_name": row.get("repo", ""),
|
||||
"token_id": row.get("token_id", ""),
|
||||
"user": row.get("user", ""),
|
||||
"token_value": row.get("token_value", ""),
|
||||
}
|
||||
for row in scan_rows
|
||||
@@ -1208,6 +1402,7 @@ def repo_token_rows(
|
||||
"owner": row.get("org", ""),
|
||||
"repo_name": row.get("repo", ""),
|
||||
"token_id": row.get("token_id", ""),
|
||||
"user": row.get("user", ""),
|
||||
"token_value": row.get("token_value", ""),
|
||||
}
|
||||
)
|
||||
@@ -1259,20 +1454,9 @@ def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: boo
|
||||
|
||||
def scope_mask(scopes: list[str]) -> str:
|
||||
scope_set = {scope.lower() for scope in scopes}
|
||||
categories = [
|
||||
"activitypub",
|
||||
"admin",
|
||||
"issue",
|
||||
"misc",
|
||||
"notification",
|
||||
"organization",
|
||||
"package",
|
||||
"repository",
|
||||
"user",
|
||||
]
|
||||
|
||||
values = []
|
||||
for category in categories:
|
||||
for _, category in SCOPE_FIELDS:
|
||||
if f"write:{category}" in scope_set:
|
||||
values.append("w")
|
||||
elif f"read:{category}" in scope_set:
|
||||
@@ -1448,6 +1632,7 @@ def print_token_item_rows(
|
||||
if store_row:
|
||||
repo_fields_match = (
|
||||
store_row["token_id"] == row["token_id"]
|
||||
and store_row["user"] == row.get("user", "")
|
||||
and store_row["token_value"] == token_value
|
||||
and store_row["owner"] == row["owner"]
|
||||
and store_row["repo_name"] == row["repo_name"]
|
||||
@@ -1458,7 +1643,7 @@ def print_token_item_rows(
|
||||
marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*"
|
||||
append_token_row(
|
||||
endpoint,
|
||||
synced_store_row.get("user", "") if synced_store_row else "",
|
||||
synced_store_row.get("user", "") if synced_store_row else row.get("user", ""),
|
||||
row["remote"],
|
||||
token_id,
|
||||
token_value,
|
||||
@@ -1491,7 +1676,7 @@ def print_token_item_rows(
|
||||
def resolve_token_from_store(
|
||||
token_data: dict,
|
||||
endpoint: str | None,
|
||||
token_id: str | None,
|
||||
remote_id: str | None,
|
||||
user_name: str | None,
|
||||
) -> dict:
|
||||
candidates = []
|
||||
@@ -1499,7 +1684,7 @@ def resolve_token_from_store(
|
||||
token_endpoint = token_server_endpoint(token_record)
|
||||
if endpoint and token_endpoint != endpoint:
|
||||
continue
|
||||
if token_id and token_record.get("token_id") != token_id:
|
||||
if remote_id and token_record_id(token_record) != remote_id:
|
||||
continue
|
||||
if user_name and token_record.get("user") != user_name:
|
||||
continue
|
||||
@@ -1510,11 +1695,11 @@ def resolve_token_from_store(
|
||||
if len(candidates) > 1:
|
||||
available = ", ".join(
|
||||
sorted(
|
||||
f"{token_server_endpoint(token_record)}:{token_record.get('token_id', '')}"
|
||||
f"{token_server_endpoint(token_record)}:{token_record_id(token_record)}"
|
||||
for token_record in candidates
|
||||
)
|
||||
)
|
||||
raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name. Available: {available}")
|
||||
raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name or --remote. Available: {available}")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
@@ -1585,10 +1770,10 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
print(f"host\t{server_entry.get('host', '')}")
|
||||
print(f"port\t{server_entry.get('port', '')}")
|
||||
print(f"tokens\t{count_server_tokens(server_entry)}")
|
||||
for row in sorted(server_entry.get("tokens", []), key=lambda item: (item.get("token_id", ""), item.get("remote", ""))):
|
||||
for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")):
|
||||
token_value = row.get("token_value", "")
|
||||
secret_value = token_value if args.show_secrets else mask_secret(token_value)
|
||||
print(f"token\t{row.get('token_id', '')}\t{secret_value}")
|
||||
print(f"id\t{row.get('token_id', '')}\t{secret_value}")
|
||||
for field_name in ["user", "valid", "scope_mask", "expires_at"]:
|
||||
field_value = row.get(field_name, "")
|
||||
if field_value:
|
||||
@@ -1622,6 +1807,26 @@ def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
print_status_counts(status_counts)
|
||||
|
||||
|
||||
def print_token_action_result(result: dict[str, str]) -> None:
|
||||
for field_name in ["repo_root", "remote", "server", "user", "org", "repo", "valid", "status"]:
|
||||
field_value = result.get(field_name)
|
||||
if field_value is not None:
|
||||
print(f"{field_name}\t{field_value}")
|
||||
|
||||
|
||||
def run_tokens_sync(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
if args.direction == "remote":
|
||||
result = sync_token_from_remote(config, resolve_repo_argument(args.repo), args.remote_id, args.dry_run)
|
||||
print_token_action_result(result)
|
||||
return
|
||||
if args.direction == "store":
|
||||
args.remote = args.remote_id
|
||||
args.token_name = args.remote_id
|
||||
run_tokens_write(config, args)
|
||||
return
|
||||
raise SystemExit(f"Unsupported tokens sync direction: {args.direction}")
|
||||
|
||||
|
||||
def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
server_url = (args.server or config.git.base_url).rstrip("/")
|
||||
server_info = server_info_from_url(config, server_url)
|
||||
@@ -1629,32 +1834,29 @@ def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
raise SystemExit(f"Only http/https server URLs are supported: {server_url}")
|
||||
|
||||
token_data = load_token_store(config)
|
||||
existing_token = find_token_record(token_data, server_info["endpoint"], args.token_id)
|
||||
remote_id = args.token_id
|
||||
if args.remote and args.remote != remote_id:
|
||||
raise SystemExit("Token id must match --remote because tokens.json is keyed 1:1 by git remote name.")
|
||||
|
||||
existing_token = find_token_record(token_data, server_info["endpoint"], remote_id)
|
||||
if existing_token is not None:
|
||||
raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{args.token_id}")
|
||||
raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{remote_id}")
|
||||
|
||||
token_record = {
|
||||
"token_id": args.token_id,
|
||||
"id": remote_id,
|
||||
"value": args.value or "",
|
||||
"server": server_record_from_info(server_info),
|
||||
"remotes": [],
|
||||
"org": args.org or "",
|
||||
"repo": args.repo_name or "",
|
||||
}
|
||||
if args.user:
|
||||
token_record["user"] = args.user
|
||||
if args.remote:
|
||||
token_record["remotes"].append(
|
||||
{
|
||||
"name": args.remote,
|
||||
"org": args.org or "",
|
||||
"repo": args.repo_name or "",
|
||||
}
|
||||
)
|
||||
|
||||
print(f"token_path\t{config.token_path}")
|
||||
print(f"server\t{server_info['endpoint']}")
|
||||
print(f"token_id\t{args.token_id}")
|
||||
print(f"id\t{remote_id}")
|
||||
print(f"value\t{'set' if token_record['value'] else 'empty'}")
|
||||
print(f"remote\t{args.remote or ''}")
|
||||
print(f"remote\t{remote_id}")
|
||||
print(f"status\t{'dry-run' if args.dry_run else 'added'}")
|
||||
|
||||
if args.dry_run:
|
||||
@@ -1686,19 +1888,22 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
token_record = resolve_token_from_store(
|
||||
token_data,
|
||||
args.server or target_server_info["endpoint"],
|
||||
args.token_name,
|
||||
args.token_name or args.remote,
|
||||
args.user,
|
||||
)
|
||||
server_endpoint = token_server_endpoint(token_record)
|
||||
token_id = str(token_record.get("token_id", ""))
|
||||
remote_id = token_record_id(token_record)
|
||||
user_name = str(token_record.get("user", ""))
|
||||
token_value = str(token_record.get("value", ""))
|
||||
if not token_value:
|
||||
raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{token_id}")
|
||||
raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{remote_id}")
|
||||
if not user_name:
|
||||
raise SystemExit(f"Token user is empty in tokens.json: {server_endpoint}:{remote_id}")
|
||||
|
||||
final_url = credentialed_remote_url(target_url, token_id, token_value)
|
||||
final_url = credentialed_remote_url(target_url, user_name, token_value)
|
||||
status = "added" if existing_remote_url is None else "updated"
|
||||
existing_credentials = remote_credentials(existing_remote_url) if existing_remote_url else None
|
||||
if existing_credentials == (token_id, token_value):
|
||||
if existing_credentials == (user_name, token_value):
|
||||
status = "unchanged"
|
||||
elif existing_credentials is not None and not args.replace:
|
||||
raise SystemExit(
|
||||
@@ -1709,8 +1914,8 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
print(f"repo_root\t{repo_root}")
|
||||
print(f"remote\t{args.remote}")
|
||||
print(f"server\t{server_endpoint}")
|
||||
print(f"user\t{token_record.get('user', '')}")
|
||||
print(f"token_name\t{token_id}")
|
||||
print(f"user\t{user_name}")
|
||||
print(f"id\t{remote_id}")
|
||||
print(f"status\t{status}")
|
||||
print(f"url\t{sanitize_remote_url(final_url)[0]}")
|
||||
|
||||
@@ -1721,6 +1926,11 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None:
|
||||
if args.remote_id:
|
||||
result = hydrate_token_from_api(config, args.remote_id, args.server, args.dry_run)
|
||||
print_token_action_result(result)
|
||||
return
|
||||
|
||||
if args.from_source == "remotes":
|
||||
if args.dry_run:
|
||||
run_tokens_scan(config, args)
|
||||
@@ -1736,7 +1946,7 @@ def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None
|
||||
if args.from_source == "store":
|
||||
run_tokens_write(config, args)
|
||||
return
|
||||
raise SystemExit(f"Unsupported tokens update source: {args.from_source}")
|
||||
raise SystemExit("Pass REMOTE_ID for API metadata refresh or use legacy --from remotes|store.")
|
||||
|
||||
|
||||
def git_current_branch(config: WorkspaceConfig, repo_path: Path) -> str:
|
||||
@@ -1959,7 +2169,7 @@ def add_repo_option(parser: argparse.ArgumentParser) -> None:
|
||||
def add_store_selection_options(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--server", help="Server endpoint from tokens.json.")
|
||||
parser.add_argument("--user", help="API user recorded on the token.")
|
||||
parser.add_argument("--token-name", help="Token id inside tokens.json, for example 't1'.")
|
||||
parser.add_argument("--token-name", help="Remote id inside tokens.json, for example 'r1'. Default: --remote.")
|
||||
|
||||
|
||||
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
||||
@@ -2017,7 +2227,7 @@ def print_main_overview(config_path: Path) -> None:
|
||||
["list-cards", "[series]", "list cards in a selected series"],
|
||||
["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
|
||||
["submission", "[series] [card] --class K --nick N", "prepare answer repo and student branch"],
|
||||
["tokens", "scan|add|read|stats|write|update", "manage tokens.json and Git remote credentials"],
|
||||
["tokens", "scan|sync|update|add|read|stats|write", "manage tokens.json and Git remote credentials"],
|
||||
],
|
||||
)
|
||||
print()
|
||||
@@ -2054,11 +2264,13 @@ def print_tokens_overview() -> None:
|
||||
["command", "common options", "direction", "purpose"],
|
||||
[
|
||||
["scan", "[--repo PATH]", "read-only", "print token table with remote/store marker and auth masks"],
|
||||
["add", "TOKEN_ID", "tokens.json", "add an empty token skeleton for manual editing"],
|
||||
["read", "[--server ENDPOINT]", "tokens.json", "show servers, token ids and remotes"],
|
||||
["add", "REMOTE_ID", "tokens.json", "add an empty token skeleton for manual editing"],
|
||||
["read", "[--server ENDPOINT]", "tokens.json", "show servers, remote ids and tokens"],
|
||||
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"],
|
||||
["sync remote", "REMOTE_ID [--repo PATH]", "repo -> tokens.json", "copy one remote URL into the store"],
|
||||
["sync store", "REMOTE_ID [--repo PATH]", "tokens.json -> repo", "write one store record into a remote URL"],
|
||||
["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"],
|
||||
["update", "--from remotes|store", "selected", "copy token data in an explicit direction"],
|
||||
["update", "REMOTE_ID", "API -> tokens.json", "refresh valid/scope/org/repo metadata"],
|
||||
],
|
||||
)
|
||||
print()
|
||||
@@ -2067,9 +2279,12 @@ def print_tokens_overview() -> None:
|
||||
["case", "command"],
|
||||
[
|
||||
["scan current repo", "./rvctl tokens scan"],
|
||||
["read remote r1 into store", "./rvctl tokens sync remote r1"],
|
||||
["refresh r1 metadata", "./rvctl tokens update r1"],
|
||||
["write store r1 to remote", "./rvctl tokens sync store r1 --repo PATH"],
|
||||
["scan selected card", "./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03"],
|
||||
["compare state", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03"],
|
||||
["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL --token-name t1"],
|
||||
["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL"],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2177,11 +2392,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"add",
|
||||
help="Add an empty token skeleton to tokens.json.",
|
||||
)
|
||||
tokens_add_parser.add_argument("token_id", help="Token id, for example 't1' or 'r6'.")
|
||||
tokens_add_parser.add_argument("token_id", metavar="REMOTE_ID", help="Remote id, for example 'r1' or 'r1a'.")
|
||||
tokens_add_parser.add_argument("--server", help="Server endpoint. Default: git.base_url from workspace.json.")
|
||||
tokens_add_parser.add_argument("--value", default="", help="Optional token value. Default: empty for manual editing.")
|
||||
tokens_add_parser.add_argument("--user", help="Optional API user label.")
|
||||
tokens_add_parser.add_argument("--remote", help="Optional remote name to attach to this token.")
|
||||
tokens_add_parser.add_argument("--remote", help="Compatibility alias. If used, it must match REMOTE_ID.")
|
||||
tokens_add_parser.add_argument("--org", help="Optional remote organization.")
|
||||
tokens_add_parser.add_argument("--repo", dest="repo_name", help="Optional remote repository.")
|
||||
tokens_add_parser.add_argument("--dry-run", action="store_true", help="Print the planned token without writing it.")
|
||||
@@ -2193,6 +2408,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
add_repo_option(tokens_stats_parser)
|
||||
tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.")
|
||||
|
||||
tokens_sync_parser = tokens_subparsers.add_parser(
|
||||
"sync",
|
||||
help="Synchronize one remote id between git remote URL and tokens.json.",
|
||||
)
|
||||
tokens_sync_parser.add_argument("direction", choices=["remote", "store"], help="'remote' reads git remote into store; 'store' writes store into git remote.")
|
||||
tokens_sync_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.")
|
||||
add_repo_option(tokens_sync_parser)
|
||||
tokens_sync_parser.add_argument("--url", help="Target remote URL used when direction is 'store' and the remote does not exist yet.")
|
||||
add_store_selection_options(tokens_sync_parser)
|
||||
tokens_sync_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing store -> remote.")
|
||||
tokens_sync_parser.add_argument("--dry-run", action="store_true", help="Print the planned sync without writing it.")
|
||||
|
||||
tokens_write_parser = tokens_subparsers.add_parser(
|
||||
"write",
|
||||
help="Write credentials from tokens.json into a repo remote URL.",
|
||||
@@ -2206,14 +2433,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
tokens_update_parser = tokens_subparsers.add_parser(
|
||||
"update",
|
||||
help="Synchronize token data in a selected direction.",
|
||||
help="Refresh API metadata for one remote id, or use a legacy --from direction.",
|
||||
)
|
||||
tokens_update_parser.add_argument("remote_id", nargs="?", help="Remote id to refresh from API, for example 'r1'.")
|
||||
tokens_update_parser.add_argument(
|
||||
"--from",
|
||||
dest="from_source",
|
||||
required=True,
|
||||
choices=["remotes", "store"],
|
||||
help="Synchronization direction source: 'remotes' or 'store'.",
|
||||
help="Legacy synchronization direction source: 'remotes' or 'store'.",
|
||||
)
|
||||
add_repo_option(tokens_update_parser)
|
||||
tokens_update_parser.add_argument("--remote", help="Remote name used when --from store.")
|
||||
@@ -2266,6 +2493,9 @@ def main() -> int:
|
||||
if args.tokens_command == "stats":
|
||||
run_tokens_stats(config, args)
|
||||
return 0
|
||||
if args.tokens_command == "sync":
|
||||
run_tokens_sync(config, args)
|
||||
return 0
|
||||
if args.tokens_command == "write":
|
||||
run_tokens_write(config, args)
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user