#!/usr/bin/env python3 """STEM workspace launcher (``stemctl`` with ``rvctl`` compatibility).""" from __future__ import annotations import argparse import base64 import datetime as dt import json import os import re import shlex import shutil import stat import subprocess import sys from dataclasses import dataclass, replace from pathlib import Path from urllib import error as urlerror from urllib import request as urlrequest from urllib.parse import quote, unquote, urlsplit, urlunsplit def expand_path(raw_path: str, base_dir: Path) -> Path: expanded = os.path.expandvars(os.path.expanduser(raw_path)) path = Path(expanded) if not path.is_absolute(): path = base_dir / path return path.resolve() @dataclass class GitConfig: base_url: str source_org: str answer_org: str source_remote: str answer_remote: str origin_remote: str fallback_branch: str @dataclass class WorkspaceConfig: config_path: Path original_root: Path original_series_root: Path workspace_root: Path workspace_info_path: Path workspace_info_url: str workspace_info: dict | None series_root: Path socket_root: Path token_path: Path tools_root_candidates: list[Path] tools_root: Path env_tool_url: str defaults: dict[str, str] git: GitConfig @dataclass(frozen=True) class TaskEntry: name: str path: Path @dataclass(frozen=True) class CardInfo: series_id: str card_id: str repo: str branch: str title: str source_org: str answer_org: str source_remote: str answer_remote: str fallback_branch: str source_path: Path | None = None SCOPE_FIELDS = [ ("a", "activitypub"), ("A", "admin"), ("i", "issue"), ("m", "misc"), ("n", "notification"), ("o", "organization"), ("p", "package"), ("r", "repository"), ("u", "user"), ] SCOPE_CATEGORY_ALIASES = { "organization": ["organization", "org"], "repository": ["repository", "repo"], } ORG_PERMISSION_FIELDS = [ ("o", "owner"), ("a", "admin"), ("w", "write"), ("r", "read"), ("c", "create"), ] REPO_PERMISSION_FIELDS = [ ("o", "owner"), ("a", "admin"), ("w", "write"), ("r", "read"), ] ENV_PROFILES = { "native-amd64": { "service": "native-amd64", "image": "localhost/stem/dev-native-amd64:local", "purpose": "native AMD64 C/C++ tests, sanitizers and GDB/LLDB", }, "hazard3-sim": { "service": "hazard3-sim", "image": "localhost/stem/dev-hazard3-sim:local", "purpose": "Hazard3 RTL, RV32 bare metal/FreeRTOS and GDB", }, "rp2350": { "service": "rp2350", "image": "localhost/stem/dev-rp2350:local", "purpose": "physical RP2350 RISC-V or ARM build, deploy and debug", }, } ENV_PROFILE_ALIASES = { "host": "native-amd64", "native": "native-amd64", "amd64": "native-amd64", "rv32i": "hazard3-sim", "riscv": "hazard3-sim", "riscv32": "hazard3-sim", "hazard3": "hazard3-sim", } ENV_PROFILE_CHOICES = sorted(set(ENV_PROFILES) | set(ENV_PROFILE_ALIASES)) PROFILE_DEFAULT_TARGETS = { "native-amd64": "native", "hazard3-sim": "hazard3-baremetal", "rp2350": "rp2350-rv", } def default_runtime_socket_root(workspace_root: Path) -> Path: xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "").strip() if xdg_runtime: return Path(xdg_runtime).expanduser().resolve() / "stem" run_user = Path(f"/run/user/{os.getuid()}") if run_user.is_dir(): return run_user / "stem" return workspace_root / ".runtime" / "stem" def load_config(config_path: Path) -> WorkspaceConfig: raw = json.loads(config_path.read_text(encoding="utf-8")) base_dir = config_path.parent original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/stem"), base_dir) configured_workspace = os.environ.get("STEM_WORKSPACE_ROOT") or raw.get( "workspace_root", "~/dev/workspace/stem" ) workspace_root = expand_path(str(configured_workspace), base_dir) legacy_workspace_root = expand_path( str(raw.get("legacy_workspace_root", "~/dev/workspace/rv")), base_dir ) if ( "STEM_WORKSPACE_ROOT" not in os.environ and not workspace_root.exists() and legacy_workspace_root.exists() ): workspace_root = legacy_workspace_root git_raw = raw.get("git", {}) base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/") original_series_root = expand_path(raw.get("original_series_root", str(original_root / "series")), base_dir) series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir) configured_socket_root = os.environ.get("STEM_SOCKET_ROOT") or raw.get("socket_root") socket_root = ( expand_path(str(configured_socket_root), base_dir) if configured_socket_root else default_runtime_socket_root(workspace_root) ) token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir) workspace_info_path = expand_path( raw.get("workspace_info_path", str(workspace_root / "meta" / "workspace-info" / "workspace.json")), base_dir, ) candidate_values = raw.get("tools_root_candidates") if not candidate_values: candidate_values = [ raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-student-env")), str(workspace_root / "tools" / "rv32i-hazard3-env"), str(Path.home() / "dev" / "workspace" / "stem" / "tools" / "rv32i-hazard3-student-env"), str(Path.home() / "dev" / "workspace" / "rv" / "tools" / "rv32i-hazard3-student-env"), str(original_root / "rv32i-hazard3-student-env"), str(original_root / "rv32i-hazard3-env"), ] tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values] adjacent_tools = (base_dir.parent / "rv32i-hazard3-student-env").resolve() if adjacent_tools.is_dir() and adjacent_tools not in tools_root_candidates: tools_root_candidates.insert(0, adjacent_tools) tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0]) env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-tools/rv32i-hazard3-student-env.git") defaults = raw.get("defaults", {}) workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git") git = GitConfig( base_url=base_url, source_org=git_raw.get("source_org", "edu-inf"), answer_org=git_raw.get("answer_org", "c2025-1a-inf"), source_remote=git_raw.get("source_remote", "r1"), answer_remote=git_raw.get("answer_remote", "r1a"), origin_remote=git_raw.get("origin_remote", "origin"), fallback_branch=git_raw.get("fallback_branch", "build"), ) workspace_info = json.loads(workspace_info_path.read_text(encoding="utf-8")) if workspace_info_path.is_file() else None return WorkspaceConfig( config_path=config_path, original_root=original_root, original_series_root=original_series_root, workspace_root=workspace_root, workspace_info_path=workspace_info_path, workspace_info_url=workspace_info_url, workspace_info=workspace_info, series_root=series_root, socket_root=socket_root, token_path=token_path, tools_root_candidates=tools_root_candidates, tools_root=tools_root, env_tool_url=env_tool_url, defaults=defaults, git=git, ) def write_workspace_config(config: WorkspaceConfig, raw_config: dict) -> None: config.config_path.parent.mkdir(parents=True, exist_ok=True) temp_path = config.config_path.with_suffix(config.config_path.suffix + ".tmp") temp_path.write_text(json.dumps(raw_config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") temp_path.replace(config.config_path) def update_config_defaults(config: WorkspaceConfig, updates: dict[str, str]) -> dict[str, str]: raw_config = json.loads(config.config_path.read_text(encoding="utf-8")) defaults = raw_config.get("defaults") if not isinstance(defaults, dict): defaults = {} raw_config["defaults"] = defaults result: dict[str, str] = {} changed = False for key, value in updates.items(): old_value = str(defaults.get(key, "")) if old_value != value: defaults[key] = value changed = True config.defaults[key] = value result[f"old_{key}"] = old_value result[key] = value if changed: write_workspace_config(config, raw_config) result["status"] = "updated" else: result["status"] = "unchanged" result["config"] = str(config.config_path) return result def workspace_info_root(config: WorkspaceConfig) -> Path: return config.workspace_info_path.parent def read_workspace_info_file(config: WorkspaceConfig, raw_path: str) -> dict: info_path = expand_path(raw_path, workspace_info_root(config)) return json.loads(info_path.read_text(encoding="utf-8")) def workspace_series_manifests(config: WorkspaceConfig) -> dict[str, dict]: if not config.workspace_info: return {} manifests: dict[str, dict] = {} for entry in config.workspace_info.get("series", []): if not isinstance(entry, dict): continue series_id = str(entry.get("id", "")).strip() if not series_id: continue manifest = dict(entry) file_name = entry.get("file") if isinstance(file_name, str) and file_name: manifest = read_workspace_info_file(config, file_name) manifest.setdefault("id", series_id) manifests[series_id] = manifest return manifests def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict | None: return workspace_series_manifests(config).get(series_name) def series_exists(config: WorkspaceConfig, series_name: str) -> bool: if workspace_series_manifest(config, series_name) is not None: return True return (config.series_root / series_name).is_dir() or (config.original_series_root / series_name).is_dir() def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None: manifest = workspace_series_manifest(config, series_name) if manifest and isinstance(manifest.get("default_card"), str): return manifest["default_card"] return config.defaults.get("card") def config_for_card(config: WorkspaceConfig, card_info: CardInfo) -> WorkspaceConfig: return replace( config, git=replace( config.git, source_org=card_info.source_org, answer_org=card_info.answer_org, source_remote=card_info.source_remote, answer_remote=card_info.answer_remote, fallback_branch=card_info.fallback_branch, ), ) def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: manifest = workspace_series_manifest(config, series_name) if not manifest: return [] source_org = str(manifest.get("source_org", config.git.source_org)) answer_org = str(manifest.get("answer_org", config.git.answer_org)) source_remote = str(manifest.get("source_remote", config.git.source_remote)) answer_remote = str(manifest.get("answer_remote", config.git.answer_remote)) fallback_branch = str(manifest.get("fallback_branch", config.git.fallback_branch)) cards = [] for raw_card in manifest.get("cards", []): if not isinstance(raw_card, dict): continue card_id = str(raw_card.get("id", "")).strip() if not card_id: continue repo_name = str(raw_card.get("repo", card_id)).strip() branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch title = str(raw_card.get("title", "")).strip() source_path = config.original_series_root / series_name / repo_name cards.append( CardInfo( series_id=series_name, card_id=card_id, repo=repo_name, branch=branch_name, title=title, source_org=source_org, answer_org=answer_org, source_remote=source_remote, answer_remote=answer_remote, fallback_branch=fallback_branch, source_path=source_path if source_path.is_dir() else None, ) ) return cards def card_infos_from_source_dirs(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: return [ CardInfo( series_id=series_name, card_id=card_label_from_repo_name(path.name), repo=path.name, branch=git_current_branch(config, path), title=read_card_title(path), source_org=config.git.source_org, answer_org=config.git.answer_org, source_remote=config.git.source_remote, answer_remote=config.git.answer_remote, fallback_branch=config.git.fallback_branch, source_path=path, ) for path in source_card_directories(config, series_name) ] def source_card_infos(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: manifest_cards = card_infos_from_manifest(config, series_name) if manifest_cards: return manifest_cards return card_infos_from_source_dirs(config, series_name) def series_directories(config: WorkspaceConfig) -> list[Path]: config.series_root.mkdir(parents=True, exist_ok=True) return sorted(path for path in config.series_root.iterdir() if path.is_dir()) def card_directories(series_path: Path) -> list[Path]: return sorted(path for path in series_path.iterdir() if path.is_dir()) def source_series_directories(config: WorkspaceConfig) -> list[Path]: if not config.original_series_root.is_dir(): return [] return sorted(path for path in config.original_series_root.iterdir() if path.is_dir()) def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]: source_series_path = config.original_series_root / series_name if not source_series_path.is_dir(): return [] return sorted(path for path in source_series_path.iterdir() if path.is_dir()) def card_label_from_repo_name(repo_name: str) -> str: if repo_name.startswith("lab-"): repo_name = repo_name[4:] marker = "-strlen-" if marker in repo_name: return repo_name.split(marker, 1)[1].split("-", 1)[0] return repo_name def card_info_matches(card_info: CardInfo, normalized: str) -> bool: candidates = {card_info.card_id, card_info.repo, card_label_from_repo_name(card_info.repo)} return normalized in candidates or any(normalized in candidate for candidate in candidates) def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo: cards = source_card_infos(config, series_name) if not cards: source_hint = config.original_series_root / series_name if config.workspace_info_path.is_file(): source_hint = config.workspace_info_path raise SystemExit(f"Missing source series: {source_hint}") normalized = slug_value(card_name, "card") matches = [card_info for card_info in cards if card_info_matches(card_info, normalized)] if not matches: available = ", ".join(card.card_id for card in cards) raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}") if len(matches) > 1: available = ", ".join(card.repo for card in matches) raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}") return matches[0] def resolve_source_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: manifest_card = resolve_card_info(config, series_name, card_name) if manifest_card.source_path: return manifest_card.source_path return config.original_series_root / series_name / manifest_card.repo def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: cards = source_card_directories(config, series_name) if not cards: raise SystemExit(f"Missing source series: {config.original_series_root / series_name}") normalized = slug_value(card_name, "card") matches = [] for card_path in cards: repo_name = card_path.name label = card_label_from_repo_name(repo_name) candidates = {repo_name, label} if normalized in candidates or any(normalized in candidate for candidate in candidates): matches.append(card_path) if not matches: available = ", ".join(card_label_from_repo_name(path.name) for path in cards) raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}") if len(matches) > 1: available = ", ".join(path.name for path in matches) raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}") return matches[0] def read_card_title(card_path: Path) -> str: readme_path = card_path / "README.md" if not readme_path.is_file(): return "" for line in readme_path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if stripped.startswith("#"): return stripped.lstrip("#").strip() return "" def resolve_selector( config: WorkspaceConfig, series_arg: str | None, card_arg: str | None, ) -> tuple[str, str]: default_series = config.defaults.get("series") default_card = config.defaults.get("card") if series_arg and "/" in series_arg: if card_arg: raise SystemExit("Pass either ' ' or '', not both forms at once.") series_value, card_value = series_arg.split("/", 1) return series_value, card_value if series_arg and not card_arg: if series_exists(config, series_arg): series_default_card = default_card_for_series(config, series_arg) if not series_default_card: raise SystemExit("Missing default card in config.") return series_arg, series_default_card if default_series and (config.series_root / default_series / series_arg).is_dir(): return default_series, series_arg if default_series: try: source_card_path = resolve_source_card_path(config, default_series, series_arg) if workspace_card_path(config, default_series, source_card_path).is_dir(): return default_series, series_arg except SystemExit: pass series_value = series_arg or default_series card_value = card_arg or default_card if not series_value or not card_value: raise SystemExit("Missing series/card selector and no defaults are configured.") return series_value, card_value def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: card_path = config.series_root / series_name / card_name if card_path.is_dir(): return card_path card_info = resolve_card_info(config, series_name, card_name) resolved_path = workspace_card_path_for_info(config, card_info) if resolved_path.is_dir(): return resolved_path raise SystemExit(f"Missing card path: {resolved_path}") def print_series(config: WorkspaceConfig) -> None: config.series_root.mkdir(parents=True, exist_ok=True) manifest_by_name = workspace_series_manifests(config) source_by_name = {path.name: path for path in source_series_directories(config)} workspace_by_name = {path.name: path for path in series_directories(config)} for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)): manifest = manifest_by_name.get(series_name) workspace_path = workspace_by_name.get(series_name) source_count = len(source_card_infos(config, series_name)) if (manifest or series_name in source_by_name) else 0 workspace_count = len(card_directories(workspace_path)) if workspace_path else 0 print(f"{series_name}\t{source_count}\t{workspace_count}\t{workspace_path or ''}") def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None: series_name = series_arg or config.defaults.get("series") if not series_name: raise SystemExit("Missing series name and no default series is configured.") config.series_root.mkdir(parents=True, exist_ok=True) source_cards = {card.repo: card for card in source_card_infos(config, series_name)} workspace_series_path = config.series_root / series_name workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {} if not source_cards and not workspace_cards: raise SystemExit(f"Missing series: {series_name}") for repo_name in sorted(set(source_cards) | set(workspace_cards)): source_card = source_cards.get(repo_name) workspace_path = workspace_cards.get(repo_name) title = read_card_title(workspace_path) if workspace_path else "" if not title and source_card: title = source_card.title if not title and source_card and source_card.source_path: title = read_card_title(source_card.source_path) label = source_card.card_id if source_card else card_label_from_repo_name(repo_name) status = "workspace" if workspace_path else "source" if title: print(f"{label}\t{repo_name}\t{status}\t{title}") else: print(f"{label}\t{repo_name}\t{status}") def slug_value(raw_value: str, field_name: str) -> str: slug = re.sub(r"[^a-z0-9._-]+", "-", raw_value.strip().lower()).strip("-") if not slug: raise SystemExit(f"Empty {field_name} after slug conversion: {raw_value!r}") return slug def branch_value(raw_value: str, field_name: str = "branch") -> str: branch_name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_value.strip()).strip("-") if not branch_name: raise SystemExit(f"Empty {field_name} after branch conversion: {raw_value!r}") return branch_name def git_capture(repo_path: Path, git_args: list[str]) -> str | None: result = subprocess.run( ["git", "-C", str(repo_path)] + git_args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False, ) if result.returncode != 0: return None return result.stdout.strip() def git_repo_root(repo_path: Path) -> Path | None: repo_root = git_capture(repo_path, ["rev-parse", "--show-toplevel"]) if not repo_root: return None return Path(repo_root) def default_port_for_scheme(scheme: str) -> int | None: if scheme == "http": return 80 if scheme == "https": return 443 return None def endpoint_from_split(split_url) -> tuple[str, int | None]: host_name = split_url.hostname if not host_name: raise SystemExit(f"Missing hostname in URL: {split_url.geturl()}") port_value = split_url.port or default_port_for_scheme(split_url.scheme) default_port = default_port_for_scheme(split_url.scheme) if port_value is not None and port_value != default_port: endpoint = f"{split_url.scheme}://{host_name}:{port_value}" else: endpoint = f"{split_url.scheme}://{host_name}" return endpoint, port_value def server_type_from_split(config: WorkspaceConfig, split_url) -> str: endpoint, _ = endpoint_from_split(split_url) base_split = urlsplit(config.git.base_url) base_endpoint, _ = endpoint_from_split(base_split) if endpoint == base_endpoint: return "gitea" host_name = (split_url.hostname or "").lower() if host_name == "github.com" or host_name.endswith(".github.com"): return "github" if "gitlab" in host_name: return "gitlab" if "gitea" in host_name: return "gitea" return "unknown" def repo_parts_from_url(remote_url: str) -> tuple[str, str]: split_url = urlsplit(remote_url) repo_path = unquote(split_url.path).strip("/") if repo_path.endswith(".git"): repo_path = repo_path[:-4] path_parts = [part for part in repo_path.split("/") if part] if len(path_parts) < 2: return "", "" return path_parts[0], path_parts[1] def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | None: split_url = urlsplit(remote_url) if split_url.scheme not in {"http", "https"}: return None if split_url.hostname is None: return None endpoint, port_value = endpoint_from_split(split_url) org_name, repo_name = repo_parts_from_url(remote_url) return { "endpoint": endpoint, "type": server_type_from_split(config, split_url), "scheme": split_url.scheme, "host": split_url.hostname, "port": port_value, "org": org_name, "repo": repo_name, } def empty_token_store() -> dict: return {"version": 3, "tokens": []} def next_token_name(tokens: dict[str, str]) -> str: index = 1 while f"t{index}" in tokens: index += 1 return f"t{index}" def server_record_from_info(server_info: dict) -> dict: return { "type": str(server_info.get("type", "unknown")), "endpoint": str(server_info.get("endpoint", "")), "scheme": str(server_info.get("scheme", "")), "host": str(server_info.get("host", "")), "port": server_info.get("port"), } def normalize_server_record(config: WorkspaceConfig, raw_server: dict, endpoint: str | None = None) -> dict | None: if not isinstance(raw_server, dict): return None endpoint_value = str(raw_server.get("endpoint") or endpoint or "") if not endpoint_value: return None split_endpoint = urlsplit(endpoint_value) host_value = raw_server.get("host") or split_endpoint.hostname or "" scheme_value = raw_server.get("scheme") or split_endpoint.scheme port_value = raw_server.get("port") if port_value is None and split_endpoint.port is not None: port_value = split_endpoint.port server_type = raw_server.get("type") or "unknown" if server_type == "unknown" and scheme_value and host_value: server_url = endpoint_value server_type = server_type_from_split(config, urlsplit(server_url)) return { "type": str(server_type), "endpoint": endpoint_value, "scheme": str(scheme_value), "host": str(host_value), "port": port_value, } def normalize_token_entry(raw_token_entry) -> str | dict | None: if isinstance(raw_token_entry, str): return raw_token_entry if raw_token_entry else None if not isinstance(raw_token_entry, dict): return None token_value = raw_token_entry.get("value") if not isinstance(token_value, str) or not token_value: return None token_entry = {"value": token_value} expires_at = raw_token_entry.get("expires_at") if isinstance(expires_at, str) and expires_at: token_entry["expires_at"] = expires_at for field_name in ["remote", "org", "repo"]: field_value = raw_token_entry.get(field_name) if isinstance(field_value, str) and field_value: token_entry[field_name] = field_value return token_entry def token_entry_value(token_entry) -> str: if isinstance(token_entry, str): return token_entry if isinstance(token_entry, dict) and isinstance(token_entry.get("value"), str): return token_entry["value"] return "" def token_entry_expires_at(token_entry) -> str: if isinstance(token_entry, dict) and isinstance(token_entry.get("expires_at"), str): return token_entry["expires_at"] return "" def token_entry_field(token_entry, field_name: str) -> str: if isinstance(token_entry, dict) and isinstance(token_entry.get(field_name), str): return token_entry[field_name] return "" def token_value_exists(tokens: dict, token_value: str) -> bool: return any(token_entry_value(existing_token) == token_value for existing_token in tokens.values()) def normalize_remote_record(raw_remote: dict) -> dict | None: if not isinstance(raw_remote, dict): return None remote_name = raw_remote.get("name") or raw_remote.get("remote") if not isinstance(remote_name, str) or not remote_name: return None remote_record = {"name": remote_name, "org": "", "repo": ""} for field_name in ["org", "repo"]: 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 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 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 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 = { "id": remote_id, "value": token_value, "server": server_record, "org": org_name, "repo": repo_name, } 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_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): raw_remotes = [ {"name": remote_name, **remote_value} 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 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) 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("id", ""), ) for existing_record in tokens: existing_key = ( existing_record.get("server", {}).get("endpoint", ""), existing_record.get("id", ""), ) if existing_key == token_key: existing_record.update(token_record) return tokens.append(token_record) def parse_datetime(raw_value: str) -> dt.datetime | None: if not raw_value: return None try: normalized_value = raw_value.replace("Z", "+00:00") return dt.datetime.fromisoformat(normalized_value) except ValueError: return None def is_expired(raw_value: str) -> bool: expires_at = parse_datetime(raw_value) if expires_at is None: return False if expires_at.tzinfo is None: now = dt.datetime.now() else: now = dt.datetime.now(expires_at.tzinfo) return expires_at <= now def valid_label(api_accepts_token: bool, expires_at: str) -> str: if not api_accepts_token or (expires_at and is_expired(expires_at)): return "invalid" if expires_at: return expires_at return "forever" def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> dict: for user_name, user_entry in raw_users.items(): if not isinstance(user_entry, dict): continue raw_tokens = user_entry.get("tokens", {}) if not isinstance(raw_tokens, dict): continue for token_name, raw_token_entry in raw_tokens.items(): token_entry = normalize_token_entry(raw_token_entry) if token_entry is None: continue token_value = token_entry_value(token_entry) remote_name = token_entry_field(token_entry, "remote") or str(token_name) token_record = { "id": remote_name, "value": token_value, "server": server_record_from_info(server_info), "user": str(user_name), "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 append_token_record(token_data, token_record) return token_data def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict: if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list): token_data = empty_token_store() for raw_token in raw_data["tokens"]: for token_record in normalize_v3_tokens(config, raw_token): append_token_record(token_data, token_record) return token_data if "servers" in raw_data and isinstance(raw_data["servers"], dict): token_data = empty_token_store() for endpoint, server_entry in raw_data["servers"].items(): if not isinstance(server_entry, dict): continue base_server_info = { "endpoint": str(endpoint), "type": server_entry.get("type", "unknown"), "scheme": server_entry.get("scheme", ""), "host": server_entry.get("host", ""), "port": server_entry.get("port"), } copy_legacy_users(token_data, base_server_info, server_entry.get("users", {})) return token_data token_data = empty_token_store() if "users" in raw_data and isinstance(raw_data["users"], dict): base_server_info = server_info_from_url(config, config.git.base_url) if base_server_info is None: raise SystemExit(f"Invalid git base URL: {config.git.base_url}") copy_legacy_users(token_data, base_server_info, raw_data["users"]) return token_data def load_token_store(config: WorkspaceConfig, write_normalized: bool = True) -> dict: if not config.token_path.exists(): return empty_token_store() raw_data = json.loads(config.token_path.read_text(encoding="utf-8")) if not isinstance(raw_data, dict): raise SystemExit(f"Invalid token store format: {config.token_path}") normalized_data = normalize_token_store(config, raw_data) if write_normalized and raw_data != normalized_data: write_token_store(config, normalized_data) return normalized_data def write_token_store(config: WorkspaceConfig, token_data: dict) -> None: config.token_path.parent.mkdir(parents=True, exist_ok=True) 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) + "\n", encoding="utf-8") os.chmod(temp_path, 0o600) temp_path.replace(config.token_path) os.chmod(config.token_path, 0o600) def token_server_endpoint(token_record: dict) -> str: server_record = token_record.get("server", {}) if not isinstance(server_record, dict): return "" return str(server_record.get("endpoint", "")) def 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_id(token_record) == remote_id: return token_record return None def apply_authz_to_token(token_record: dict, authz: dict) -> bool: changed = False for source_name, target_name in [ ("user", "user"), ("valid", "valid"), ]: 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 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 unknown_permission_map(fields: list[tuple[str, str]]) -> dict[str, str]: return {short_name: "?" for short_name, _ in fields} def reset_token_api_metadata(token_record: dict) -> bool: changed = False if token_record.get("valid") != "?": token_record["valid"] = "?" changed = True if token_record.pop("expires_at", None) is not None: changed = True for field_name, fields in [ ("scope", SCOPE_FIELDS), ("org_perm", ORG_PERMISSION_FIELDS), ("repo_perm", REPO_PERMISSION_FIELDS), ]: unknown_record = unknown_permission_map(fields) if token_record.get(field_name) != unknown_record: token_record[field_name] = unknown_record changed = True return changed def register_token( token_data: dict, server_info: dict, remote_name: str, user_name: str, token_value: str, hydrate: bool = True, reset_api_metadata: bool = False, ) -> str: endpoint = server_info["endpoint"] token_record = find_token_record(token_data, endpoint, remote_name) if token_record is None: 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" if reset_api_metadata: if reset_token_api_metadata(token_record) 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 def remote_urls(repo_path: Path) -> list[str]: return [remote_url for _, remote_url in remote_name_urls(repo_path)] def remote_name_urls(repo_path: Path) -> list[tuple[str, str]]: remote_output = git_capture(repo_path, ["remote"]) if not remote_output: return [] urls: list[tuple[str, str]] = [] seen: set[tuple[str, str]] = set() for remote_name in remote_output.splitlines(): url_output = git_capture(repo_path, ["remote", "get-url", "--all", remote_name]) if not url_output: continue for remote_url in url_output.splitlines(): key = (remote_name, remote_url) if key in seen: continue seen.add(key) urls.append(key) return urls def remote_credentials(remote_url: str) -> tuple[str, str] | None: split_url = urlsplit(remote_url) if split_url.scheme not in {"http", "https"}: return None if split_url.username is None or split_url.password is None: return None return unquote(split_url.username), unquote(split_url.password) def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: repo_root = git_repo_root(repo_path) if repo_root is None: return { "repo_root": None, "remotes": 0, "urls": 0, "scanned": 0, "auth_urls": 0, "plain_urls": 0, "unsupported_urls": 0, "found": 0, "added": 0, "updated": 0, "servers": 0, "remote_rows": [], "found_credentials": [], "changed": False, } found_credentials: list[tuple[int, dict, str, str, str]] = [] remote_entries = remote_name_urls(repo_root) remote_names = {remote_name for remote_name, _ in remote_entries} touched_servers: set[str] = set() remote_rows: list[dict[str, str]] = [] auth_urls = 0 plain_urls = 0 unsupported_urls = 0 for remote_name, remote_url in remote_entries: server_info = server_info_from_url(config, remote_url) if server_info is None: unsupported_urls += 1 remote_rows.append( { "remote": remote_name, "endpoint": "", "type": "unsupported", "org": "", "repo": "", "url_kind": "unsupported", "url": safe_remote_url_label(remote_url), "token_id": "", "token_value": "", "result": "ignored", } ) continue touched_servers.add(server_info["endpoint"]) credentials = remote_credentials(remote_url) if credentials is None: plain_urls += 1 remote_rows.append( { "remote": remote_name, "endpoint": server_info["endpoint"], "type": server_info["type"], "org": server_info["org"], "repo": server_info["repo"], "url_kind": "plain", "url": safe_remote_url_label(remote_url), "token_id": "", "token_value": "", "result": "no_credentials", } ) continue auth_urls += 1 user_name, token_value = credentials row_index = len(remote_rows) remote_rows.append( { "remote": remote_name, "endpoint": server_info["endpoint"], "type": server_info["type"], "org": server_info["org"], "repo": server_info["repo"], "url_kind": "auth", "url": safe_remote_url_label(remote_url), "token_id": remote_name, "user": user_name, "token_value": token_value, "result": "found", } ) found_credentials.append((row_index, server_info, remote_name, user_name, token_value)) return { "repo_root": repo_root, "remotes": len(remote_names), "urls": len(remote_entries), "scanned": auth_urls + plain_urls, "auth_urls": auth_urls, "plain_urls": plain_urls, "unsupported_urls": unsupported_urls, "found": len(found_credentials), "added": 0, "updated": 0, "servers": len(touched_servers), "remote_rows": remote_rows, "found_credentials": found_credentials, "changed": False, } def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: report = scan_repo_remotes(config, repo_path) found_credentials = report.get("found_credentials", []) if not found_credentials: return report token_data = load_token_store(config) added = 0 updated = 0 for row_index, server_info, remote_name, user_name, token_value in found_credentials: result = register_token(token_data, server_info, remote_name, user_name, token_value) if result == "added": added += 1 elif result == "updated": updated += 1 report["remote_rows"][row_index]["result"] = result changed = added > 0 or updated > 0 if changed or not config.token_path.exists(): write_token_store(config, token_data) report["added"] = added report["updated"] = updated report["changed"] = changed 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, reset_api_metadata=True, ) 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", [])) def mask_secret(secret_value: str) -> str: if len(secret_value) <= 4: return "*" * len(secret_value) return f"{secret_value[:2]}{'*' * (len(secret_value) - 4)}{secret_value[-2:]}" def store_servers_from_tokens(token_data: dict) -> dict[str, dict]: servers: dict[str, dict] = {} for token_record in token_data.get("tokens", []): server_record = token_record.get("server", {}) if not isinstance(server_record, dict): continue endpoint = str(server_record.get("endpoint", "")) if not endpoint: continue server_entry = servers.setdefault( endpoint, { "type": server_record.get("type", "unknown"), "scheme": server_record.get("scheme", ""), "host": server_record.get("host", ""), "port": server_record.get("port"), "tokens": [], }, ) for field_name in ["type", "scheme", "host", "port"]: if server_entry.get(field_name) in {"", None, "unknown"} and server_record.get(field_name) not in {"", None}: server_entry[field_name] = server_record.get(field_name) 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 def token_store_servers(token_data: dict) -> list[tuple[str, dict]]: return sorted(store_servers_from_tokens(token_data).items()) def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str, str]]: token_pairs: set[tuple[str, str, str, str, str]] = set() for row in server_entry.get("tokens", []): token_id = row.get("token_id", "") token_value = row.get("token_value", "") if token_id and token_value: token_pairs.add( ( str(row.get("remote", "")), str(row.get("user", "")), str(token_value), str(row.get("org", row.get("owner", ""))), str(row.get("repo", row.get("repo_name", ""))), ) ) return token_pairs def count_users(users_data: dict) -> int: return len(users_data) def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tuple[Path | None, dict[str, dict]]: repo_root = git_repo_root(repo_path) if repo_root is None: return None, {} repo_servers: dict[str, dict] = {} for remote_name, remote_url in remote_name_urls(repo_root): server_info = server_info_from_url(config, remote_url) if server_info is None: continue endpoint = server_info["endpoint"] entry = repo_servers.setdefault( endpoint, { "type": server_info["type"], "scheme": server_info["scheme"], "host": server_info["host"], "port": server_info["port"], "remotes": set(), "urls": 0, "auth_urls": 0, "plain_urls": 0, "tokens": [], "plain_remotes": [], }, ) entry["remotes"].add(remote_name) entry["urls"] += 1 for field_name in ["type", "scheme", "host", "port"]: if field_name not in entry or entry[field_name] in {"", None, "unknown"}: entry[field_name] = server_info[field_name] credentials = remote_credentials(remote_url) if credentials is None: entry["plain_urls"] += 1 entry["plain_remotes"].append( { "remote": remote_name, "org": server_info["org"], "repo": server_info["repo"], } ) continue entry["auth_urls"] += 1 user_name, token_value = credentials entry["tokens"].append( { "remote": remote_name, "org": server_info["org"], "repo": server_info["repo"], "token_id": remote_name, "user": user_name, "token_value": token_value, } ) return repo_root, repo_servers def compare_server_entries(repo_entry: dict | None, store_entry: dict | None) -> tuple[str, int, int]: if repo_entry is None and store_entry is None: return "missing", 0, 0 if repo_entry is not None and store_entry is None: return "repo_only", count_server_tokens(repo_entry), 0 if repo_entry is None and store_entry is not None: return "store_only", 0, count_server_tokens(store_entry) repo_pairs = token_pairs_from_entry(repo_entry) store_pairs = token_pairs_from_entry(store_entry) repo_only_pairs = repo_pairs - store_pairs store_only_pairs = store_pairs - repo_pairs if not repo_only_pairs and not store_only_pairs: return "in_sync", 0, 0 if repo_only_pairs and not store_only_pairs: return "repo_ahead", len(repo_only_pairs), 0 if store_only_pairs and not repo_only_pairs: return "store_ahead", 0, len(store_only_pairs) return "diverged", len(repo_only_pairs), len(store_only_pairs) def repo_result_for_entry(repo_entry: dict | None) -> tuple[str, str]: if repo_entry is None: return "missing", "missing" if repo_entry.get("auth_urls", 0): return "auth", "present" if repo_entry.get("plain_urls", 0): return "plain", "no_credentials" return "empty", "no_credentials" def print_token_context( repo_root: Path | None, config: WorkspaceConfig, repo_servers: dict[str, dict], store_servers: dict[str, dict], endpoint_count: int, ) -> None: print("context") print("item\tvalue") print(f"repo_root\t{repo_root or ''}") print(f"token_path\t{config.token_path}") print(f"repo_endpoints\t{len(repo_servers)}") print(f"tokens_endpoints\t{len(store_servers)}") print(f"tokens\t{endpoint_count}") def print_status_counts(status_counts: dict[str, int]) -> None: print("status") print("item\tvalue") for status_name in ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]: print(f"{status_name}\t{status_counts[status_name]}") TOKEN_REF_WIDTH = 9 TOKEN_COLUMNS = [ ("item", "item", 4), ("server", "server", 6), ("proto", "proto", 5), ("host", "host", 18), ("owner", "org", 9), ("repo_name", "repo", 11), ("user", "user", 4), ("remote", "remote", 6), ("token_ref", "token_ref", TOKEN_REF_WIDTH), ("token", "token", 12), ("valid", "valid", 19), ("scope_mask", "scope", 9), ("org_mask", "org", 6), ("repo_mask", "repo", 6), ] TOKEN_LIST_COLUMNS = [ ("item", "item", 4), ("source", "source", 6), ("kind", "kind", 5), ("server", "server", 6), ("proto", "proto", 5), ("host", "host", 18), ("owner", "org", 9), ("repo_name", "repo", 11), ("user", "user", 4), ("remote", "remote", 6), ("token", "token", 12), ("valid", "valid", 19), ("scope_mask", "scope", 9), ("org_mask", "org", 6), ("repo_mask", "repo", 6), ] TOKEN_SCAN_COLUMNS = [ ("item", "item", 4), ("remote", "remote", 6), ("kind", "kind", 11), ("server", "server", 6), ("proto", "proto", 5), ("host", "host", 18), ("owner", "org", 9), ("repo_name", "repo", 11), ("user", "user", 4), ("token", "token", 12), ("result", "result", 14), ("url", "url", 38), ] TOKEN_SEPARATOR_OVERRIDES = { "scope_mask": "aAimnopru", "org_mask": "oawrc-", "repo_mask": "oawr--", } def fit_cell(value: str, width: int) -> str: if len(value) <= width: return value.ljust(width) if width <= 3: return value[:width] return f"{value[:width - 3]}..." def print_fixed_table(title: str, columns: list[tuple[str, str, int]], rows: list[dict[str, str]]) -> None: print(title) print(" ".join(fit_cell(label, width) for _, label, width in columns).rstrip()) print(" ".join(fit_cell(TOKEN_SEPARATOR_OVERRIDES.get(name, "-" * width), width) for name, _, width in columns).rstrip()) for row in rows: print(" ".join(fit_cell(row.get(name, ""), width) for name, _, width in columns).rstrip()) def short_secret(secret_value: str) -> str: if len(secret_value) <= 12: return mask_secret(secret_value) return f"{secret_value[:5]}...{secret_value[-4:]}" def format_token_ref(token_name: str, marker: str) -> str: name_width = TOKEN_REF_WIDTH - 2 return f"{fit_cell(token_name, name_width)}{marker:>2}" def users_label(server_entry: dict | None) -> str: if server_entry is None: return "" return ",".join(sorted(str(user_name) for user_name in server_entry.get("users", {}))) def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry: dict | None) -> dict[str, str]: values = { "server": "", "scheme": "", "host": "", "port": "", } for server_entry in [repo_entry, store_entry]: if server_entry is None: continue values["server"] = values["server"] or str(server_entry.get("type", "")) values["scheme"] = values["scheme"] or str(server_entry.get("scheme", "")) values["host"] = values["host"] or str(server_entry.get("host", "")) port_value = server_entry.get("port", "") values["port"] = values["port"] or ("" if port_value is None else str(port_value)) split_endpoint = urlsplit(endpoint) values["scheme"] = values["scheme"] or split_endpoint.scheme values["host"] = values["host"] or (split_endpoint.hostname or "") if not values["port"] and split_endpoint.port is not None: values["port"] = str(split_endpoint.port) if values["host"] and values["port"]: values["host"] = f"{values['host']}:{values['port']}" return values def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for endpoint, server_entry in sorted(store_servers.items()): for row in server_entry.get("tokens", []): token_value = row.get("token_value", "") token_id = row.get("token_id", "") if token_id: rows.append( { "endpoint": endpoint, "user": str(row.get("user", "")), "token_id": str(token_id), "remote": str(row.get("remote", "")), "owner": str(row.get("owner", "")), "repo_name": str(row.get("repo_name", "")), "token_value": str(token_value), "expires_at": str(row.get("expires_at", "")), "valid": str(row.get("valid", "")), "scope_mask": str(row.get("scope_mask", "")), "org_mask": str(row.get("org_mask", "")), "repo_mask": str(row.get("repo_mask", "")), } ) return rows def repo_token_rows( endpoint_names: list[str], repo_servers: dict[str, dict], scan_rows: list[dict[str, str]] | None, ) -> list[dict[str, str]]: if scan_rows is not None: return [ { "endpoint": row["endpoint"], "remote": row["remote"], "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 if row.get("endpoint") and row.get("url_kind") == "auth" and row.get("token_value") ] rows: list[dict[str, str]] = [] for endpoint in endpoint_names: repo_entry = repo_servers.get(endpoint) if repo_entry is None: continue for row in repo_entry.get("tokens", []): rows.append( { "endpoint": endpoint, "remote": row.get("remote", ""), "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", ""), } ) return rows def list_store_rows(store_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] endpoints = sorted(store_servers) for endpoint in endpoints: if server_filter and endpoint != server_filter: continue server_entry = store_servers[endpoint] endpoint_values = endpoint_column_values(endpoint, None, server_entry) for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")): token_value = str(row.get("token_value", "")) rows.append( { "item": str(len(rows) + 1), "source": "store", "kind": "auth" if token_value else "empty", "server": endpoint_values["server"], "proto": endpoint_values["scheme"], "host": endpoint_values["host"], "owner": str(row.get("owner", "")), "repo_name": str(row.get("repo_name", "")), "user": str(row.get("user", "")), "remote": str(row.get("remote", "")), "token": short_secret(token_value) if token_value else "", "valid": str(row.get("valid", "")), "scope_mask": str(row.get("scope_mask", "")), "org_mask": str(row.get("org_mask", "")), "repo_mask": str(row.get("repo_mask", "")), } ) return rows def list_remote_rows(repo_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] endpoints = sorted(repo_servers) for endpoint in endpoints: if server_filter and endpoint != server_filter: continue server_entry = repo_servers[endpoint] endpoint_values = endpoint_column_values(endpoint, server_entry, None) remote_rows = [] for row in server_entry.get("tokens", []): remote_rows.append( { "kind": "auth", "remote": row.get("remote", ""), "owner": row.get("org", ""), "repo_name": row.get("repo", ""), "user": row.get("user", ""), "token_value": row.get("token_value", ""), } ) for row in server_entry.get("plain_remotes", []): remote_rows.append( { "kind": "plain", "remote": row.get("remote", ""), "owner": row.get("org", ""), "repo_name": row.get("repo", ""), "user": "", "token_value": "", } ) for row in sorted(remote_rows, key=lambda item: (item.get("remote", ""), item.get("kind", ""))): token_value = str(row.get("token_value", "")) rows.append( { "item": str(len(rows) + 1), "source": "remote", "kind": str(row.get("kind", "")), "server": endpoint_values["server"], "proto": endpoint_values["scheme"], "host": endpoint_values["host"], "owner": str(row.get("owner", "")), "repo_name": str(row.get("repo_name", "")), "user": str(row.get("user", "")), "remote": str(row.get("remote", "")), "token": short_secret(token_value) if token_value else "", "valid": "?", "scope_mask": "?????????", "org_mask": "?????", "repo_mask": "????", } ) return rows def scan_remote_rows(report: dict, server_filter: str | None = None) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for row in report.get("remote_rows", []): endpoint = str(row.get("endpoint", "")) if server_filter and endpoint != server_filter: continue endpoint_values = endpoint_column_values(endpoint, None, None) if endpoint else {} token_value = str(row.get("token_value", "")) rows.append( { "item": str(len(rows) + 1), "remote": str(row.get("remote", "")), "kind": str(row.get("url_kind", "")), "server": str(row.get("type", "")), "proto": endpoint_values.get("scheme", ""), "host": endpoint_values.get("host", ""), "owner": str(row.get("org", "")), "repo_name": str(row.get("repo", "")), "user": str(row.get("user", "")), "token": short_secret(token_value) if token_value else "", "result": str(row.get("result", "")), "url": str(row.get("url", "")), } ) return rows def fallback_project_by_endpoint( endpoint_names: list[str], repo_servers: dict[str, dict], scan_rows: list[dict[str, str]] | None, ) -> dict[str, dict[str, str]]: fallback: dict[str, dict[str, str]] = {} for row in scan_rows or []: endpoint = row.get("endpoint", "") if endpoint and endpoint not in fallback: fallback[endpoint] = { "remote": row.get("remote", ""), "owner": row.get("org", ""), "repo_name": row.get("repo", ""), } for endpoint in endpoint_names: if endpoint in fallback: continue repo_entry = repo_servers.get(endpoint) if repo_entry is None: continue for row in repo_entry.get("tokens", []) + repo_entry.get("plain_remotes", []): fallback[endpoint] = { "remote": row.get("remote", ""), "owner": row.get("org", ""), "repo_name": row.get("repo", ""), } break return fallback def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: bool = False) -> object: headers = {"Accept": "application/json"} if basic: raw_credentials = f"{user_name}:{token_value}".encode("utf-8") headers["Authorization"] = "Basic " + base64.b64encode(raw_credentials).decode("ascii") else: headers["Authorization"] = f"token {token_value}" request = urlrequest.Request(api_url, headers=headers) with urlrequest.urlopen(request, timeout=5) as response: return json.loads(response.read().decode("utf-8")) def scope_mask(scopes: list[str]) -> str: scope_set = {scope.strip().lower() for scope in scopes if scope.strip()} if "all" in scope_set: return "w" * len(SCOPE_FIELDS) values = [] for _, category in SCOPE_FIELDS: category_names = SCOPE_CATEGORY_ALIASES.get(category, [category]) if any(f"write:{category_name}" in scope_set for category_name in category_names): values.append("w") elif any(f"read:{category_name}" in scope_set for category_name in category_names): values.append("r") else: values.append("-") return "".join(values) def org_permission_mask(org_permissions: dict) -> str: return "".join( [ "+" if org_permissions.get("is_owner") else "-", "+" if org_permissions.get("is_admin") else "-", "+" if org_permissions.get("can_write") else "-", "+" if org_permissions.get("can_read") else "-", "+" if org_permissions.get("can_create_repository") else "-", ] ) def repo_permission_mask(repo_permissions: dict) -> str: permission = str(repo_permissions.get("permission", "none")).lower() levels = { "owner": "++++", "admin": "-+++", "write": "--++", "read": "---+", } return levels.get(permission, "----") def load_gitea_authz( endpoint: str, token_id: str, token_value: str, expires_at: str, owner: str, repo_name: str, ) -> dict: api_base = endpoint.rstrip("/") + "/api/v1" result = { "ok": False, "valid": "!", "scope_mask": "!!!!!!!!!", "org_mask": "!!!!!", "repo_mask": "!!!!", "user": "", } try: user_info = api_get_json(f"{api_base}/user", token_value) user_name = str(user_info.get("login", "")) if isinstance(user_info, dict) else "" result["user"] = user_name result["valid"] = valid_label(True, expires_at) if user_name: try: tokens_url = f"{api_base}/users/{quote(user_name, safe='')}/tokens" token_list = api_get_json(tokens_url, token_value, user_name=user_name, basic=True) token_last8 = token_value[-8:] token_entry = next( ( entry for entry in token_list if isinstance(entry, dict) and entry.get("token_last_eight") == token_last8 ), None, ) scopes = token_entry.get("scopes", []) if isinstance(token_entry, dict) else [] if not isinstance(scopes, list): scopes = [] result["scope_mask"] = scope_mask([str(scope) for scope in scopes]) except (OSError, ValueError, urlerror.URLError): result["scope_mask"] = "?????????" if user_name and owner: org_url = f"{api_base}/users/{quote(user_name, safe='')}/orgs/{quote(owner, safe='')}/permissions" org_permissions = api_get_json(org_url, token_value) if isinstance(org_permissions, dict): result["org_mask"] = org_permission_mask(org_permissions) if user_name and owner and repo_name: repo_url = ( f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}" f"/collaborators/{quote(user_name, safe='')}/permission" ) repo_permissions = api_get_json(repo_url, token_value) if isinstance(repo_permissions, dict): result["repo_mask"] = repo_permission_mask(repo_permissions) result["ok"] = True return result except urlerror.HTTPError as error: result["valid"] = "invalid" if error.code in {401, 403} else "!" return result except (OSError, ValueError, urlerror.URLError): return result def print_token_item_rows( endpoint_names: list[str], repo_servers: dict[str, dict], store_servers: dict[str, dict], scan_rows: list[dict[str, str]] | None = None, ) -> dict[str, int]: status_names = ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"] status_counts = {status_name: 0 for status_name in status_names} for endpoint in endpoint_names: repo_entry = repo_servers.get(endpoint) store_entry = store_servers.get(endpoint) status_name, _, _ = compare_server_entries(repo_entry, store_entry) if status_name in status_counts: status_counts[status_name] += 1 store_rows = token_store_rows(store_servers) store_by_remote: dict[tuple[str, str], dict[str, str]] = {} for row in store_rows: if row["remote"]: store_by_remote.setdefault((row["endpoint"], row["remote"]), row) endpoint_values_by_name = { endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint)) for endpoint in endpoint_names } project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows) used_store_keys: set[tuple[str, str, str]] = set() token_rows: list[dict[str, str]] = [] def append_token_row( endpoint: str, user_name: str, remote_name: str, token_id: str, token_value: str, marker: str, owner: str, repo_name: str, store_row: dict | None = None, ) -> None: endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None)) valid_value = store_row.get("valid", "?") if store_row else "?" scope_value = store_row.get("scope_mask", "?????????") if store_row else "?????????" org_value = store_row.get("org_mask", "?????") if store_row else "?????" repo_value = store_row.get("repo_mask", "????") if store_row else "????" token_rows.append( { "item": str(len(token_rows) + 1), "server": endpoint_values["server"], "proto": endpoint_values["scheme"], "host": endpoint_values["host"], "owner": owner, "repo_name": repo_name, "user": user_name, "remote": remote_name, "token_ref": format_token_ref(token_id, marker), "token": short_secret(token_value), "valid": valid_value or "?", "scope_mask": scope_value or "?????????", "org_mask": org_value or "?????", "repo_mask": repo_value or "????", } ) for row in repo_token_rows(endpoint_names, repo_servers, scan_rows): endpoint = row["endpoint"] token_value = row["token_value"] store_row = store_by_remote.get((endpoint, row["remote"])) token_id = row["token_id"] expires_at = store_row.get("expires_at", "") if store_row else "" marker = "R" synced_store_row = None 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"] ) if repo_fields_match: used_store_keys.add((endpoint, store_row["token_id"], store_row["remote"])) synced_store_row = store_row marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*" append_token_row( endpoint, synced_store_row.get("user", "") if synced_store_row else row.get("user", ""), row["remote"], token_id, token_value, marker, row["owner"], row["repo_name"], synced_store_row, ) for row in store_rows: store_key = (row["endpoint"], row["token_id"], row["remote"]) if store_key in used_store_keys: continue fallback = project_fallback.get(row["endpoint"], {}) append_token_row( row["endpoint"], row["user"], row["remote"], row["token_id"], row["token_value"], "S", row["owner"] or fallback.get("owner", ""), row["repo_name"] or fallback.get("repo_name", ""), ) print_fixed_table("tokens", TOKEN_COLUMNS, token_rows) return status_counts def resolve_token_from_store( token_data: dict, endpoint: str | None, remote_id: str | None, user_name: str | None, ) -> dict: candidates = [] for token_record in token_data.get("tokens", []): token_endpoint = token_server_endpoint(token_record) if endpoint and token_endpoint != endpoint: continue if remote_id and token_record_id(token_record) != remote_id: continue if user_name and token_record.get("user") != user_name: continue candidates.append(token_record) if not candidates: raise SystemExit("No matching token in tokens.json.") if len(candidates) > 1: available = ", ".join( sorted( f"{token_server_endpoint(token_record)}:{token_record_id(token_record)}" for token_record in candidates ) ) raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name or --remote. Available: {available}") return candidates[0] def sanitize_remote_url(remote_url: str) -> tuple[str, bool]: split_url = urlsplit(remote_url) if split_url.scheme not in {"http", "https"} or split_url.hostname is None: raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}") _, port_value = endpoint_from_split(split_url) default_port = default_port_for_scheme(split_url.scheme) netloc = split_url.hostname if port_value is not None and port_value != default_port: netloc = f"{netloc}:{port_value}" had_credentials = split_url.username is not None or split_url.password is not None sanitized_url = urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment)) return sanitized_url, had_credentials def credentialed_remote_url(remote_url: str, user_name: str, token_value: str) -> str: sanitized_url, _ = sanitize_remote_url(remote_url) split_url = urlsplit(sanitized_url) _, port_value = endpoint_from_split(split_url) default_port = default_port_for_scheme(split_url.scheme) host_part = split_url.hostname or "" if port_value is not None and port_value != default_port: host_part = f"{host_part}:{port_value}" netloc = f"{quote(user_name, safe='')}:{quote(token_value, safe='')}@{host_part}" return urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment)) def remote_url_from_token_record(token_record: dict) -> str: endpoint = token_server_endpoint(token_record).rstrip("/") owner = str(token_record.get("org", "")).strip("/") repo_name = str(token_record.get("repo", "")).strip("/") if not endpoint or not owner or not repo_name: return "" if not repo_name.endswith(".git"): repo_name = f"{repo_name}.git" return f"{endpoint}/{quote(owner, safe='')}/{quote(repo_name, safe='')}" def remote_urls_point_to_same_repo(left_url: str, right_url: str) -> bool: try: left_sanitized, _ = sanitize_remote_url(left_url) right_sanitized, _ = sanitize_remote_url(right_url) except SystemExit: return False return left_sanitized.rstrip("/") == right_sanitized.rstrip("/") def find_origin_rename_candidate( config: WorkspaceConfig, repo_root: Path, target_remote: str, target_url: str, ) -> tuple[str, str] | None: origin_remote = config.git.origin_remote if not origin_remote or origin_remote == target_remote: return None if git_capture(repo_root, ["remote", "get-url", target_remote]) is not None: return None origin_url = git_capture(repo_root, ["remote", "get-url", origin_remote]) if not origin_url: return None if not remote_urls_point_to_same_repo(origin_url, target_url): return None return origin_remote, origin_url def resolve_repo_argument(repo_arg: str | None) -> Path: return Path(repo_arg).expanduser().resolve() if repo_arg else Path.cwd().resolve() def resolve_launcher_repo_argument(repo_arg: str | None) -> Path: return Path(repo_arg).expanduser().resolve() if repo_arg else Path(__file__).resolve().parent def safe_remote_url_label(remote_url: str | None) -> str: if not remote_url: return "" split_url = urlsplit(remote_url) if split_url.scheme in {"http", "https"} and split_url.hostname is not None: return sanitize_remote_url(remote_url)[0] return remote_url def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_launcher_repo_argument(args.repo) report = scan_repo_remotes(config, repo_path) print_fixed_table("remotes", TOKEN_SCAN_COLUMNS, scan_remote_rows(report, args.server)) def run_tokens_compare(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_launcher_repo_argument(args.repo) token_data = load_token_store(config, write_normalized=False) store_servers = store_servers_from_tokens(token_data) report = scan_repo_remotes(config, repo_path) repo_root, repo_servers = collect_repo_server_entries(config, repo_path) endpoint_names = sorted(set(repo_servers) | set(store_servers)) print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) def run_tokens_list(config: WorkspaceConfig, args: argparse.Namespace) -> None: rows: list[dict[str, str]] = [] if args.target in {"store", "both"}: token_data = load_token_store(config, write_normalized=False) store_servers = store_servers_from_tokens(token_data) rows.extend(list_store_rows(store_servers, args.server)) if args.target in {"remote", "both"}: repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo)) if repo_root is None: raise SystemExit(f"Missing git repo at path: {resolve_launcher_repo_argument(args.repo)}") rows.extend(list_remote_rows(repo_servers, args.server)) for index, row in enumerate(rows, start=1): row["item"] = str(index) print_fixed_table("tokens", TOKEN_LIST_COLUMNS, rows) def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: token_data = load_token_store(config, write_normalized=False) servers = token_store_servers(token_data) if args.server: server_entry = dict(store_servers_from_tokens(token_data)).get(args.server) if server_entry is None: raise SystemExit(f"Missing server endpoint in token store: {args.server}") servers = [(args.server, server_entry)] if not servers: print(f"token_path\t{config.token_path}") print("servers\t0") return print(f"token_path\t{config.token_path}") for index, (endpoint, server_entry) in enumerate(servers): if index: print() print(f"endpoint\t{endpoint}") print(f"type\t{server_entry.get('type', 'unknown')}") print(f"scheme\t{server_entry.get('scheme', '')}") print(f"host\t{server_entry.get('host', '')}") print(f"port\t{server_entry.get('port', '')}") print(f"tokens\t{count_server_tokens(server_entry)}") 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"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: print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}") if row.get("remote"): print( "remote\t" f"{row.get('token_id', '')}\t{row.get('remote', '')}\t" f"{row.get('owner', '')}\t{row.get('repo_name', '')}" ) for field_name in ["org_mask", "repo_mask"]: field_value = row.get(field_name, "") if field_value: print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}") def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo)) token_data = load_token_store(config, write_normalized=False) store_servers = store_servers_from_tokens(token_data) endpoint_names = sorted(set(repo_servers) | set(store_servers)) if args.server: if args.server not in set(endpoint_names): raise SystemExit(f"Missing server endpoint in repo/store: {args.server}") endpoint_names = [args.server] print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names)) print() status_counts = print_token_item_rows(endpoint_names, repo_servers, store_servers) print() print_status_counts(status_counts) def print_token_action_result(result: dict[str, str]) -> None: for field_name in ["repo_root", "token_path", "remote", "server", "user", "org", "repo", "valid", "url", "removed", "status"]: field_value = result.get(field_name) if field_value is not None: print(f"{field_name}\t{field_value}") def remove_token_from_store( config: WorkspaceConfig, remote_id: str, endpoint: str | None = None, dry_run: bool = False, ) -> dict[str, str]: token_data = load_token_store(config, write_normalized=False) matches = [] for index, token_record in enumerate(token_data.get("tokens", [])): if token_record_id(token_record) != remote_id: continue if endpoint and token_server_endpoint(token_record) != endpoint: continue matches.append((index, token_record)) if not matches: return { "token_path": str(config.token_path), "remote": remote_id, "server": endpoint or "", "removed": "0", "status": "missing", } if len(matches) > 1 and not endpoint: available = ", ".join(sorted(token_server_endpoint(token_record) for _, token_record in matches)) raise SystemExit(f"Multiple tokens with id '{remote_id}'. Pass --server. Available: {available}") removed_indexes = {index for index, _ in matches} first_record = matches[0][1] if not dry_run: token_data["tokens"] = [ token_record for index, token_record in enumerate(token_data.get("tokens", [])) if index not in removed_indexes ] write_token_store(config, token_data) return { "token_path": str(config.token_path), "remote": remote_id, "server": token_server_endpoint(first_record), "user": str(first_record.get("user", "")), "org": str(first_record.get("org", "")), "repo": str(first_record.get("repo", "")), "removed": str(len(matches)), "status": "dry-run" if dry_run else "removed", } def remove_git_remote(repo_path: Path, remote_id: str, dry_run: bool = False) -> dict[str, str]: 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_id]) if not remote_url: return { "repo_root": str(repo_root), "remote": remote_id, "removed": "0", "status": "missing", } if not dry_run: subprocess.run( ["git", "-C", str(repo_root), "remote", "remove", remote_id], check=True, ) return { "repo_root": str(repo_root), "remote": remote_id, "url": safe_remote_url_label(remote_url), "removed": "1", "status": "dry-run" if dry_run else "removed", } def run_tokens_remove(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.target == "store": result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run) print_token_action_result(result) return if args.target == "remote": result = remove_git_remote(resolve_launcher_repo_argument(args.repo), args.remote_id, args.dry_run) print_token_action_result(result) return if args.target == "both": repo_path = resolve_launcher_repo_argument(args.repo) if not args.dry_run: remove_token_from_store(config, args.remote_id, args.server, dry_run=True) remote_result = remove_git_remote(repo_path, args.remote_id, args.dry_run) print_token_action_result(remote_result) print() store_result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run) print_token_action_result(store_result) return raise SystemExit(f"Unsupported tokens remove target: {args.target}") def run_tokens_sync(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_launcher_repo_argument(args.repo) if args.direction == "remote": result = sync_token_from_remote(config, repo_path, args.remote_id, args.dry_run) print_token_action_result(result) return if args.direction == "store": args.repo = str(repo_path) args.remote = args.remote_id args.token_name = args.remote_id args.auto_rename_origin = True 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) if server_info is None: raise SystemExit(f"Only http/https server URLs are supported: {server_url}") token_data = load_token_store(config) 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']}:{remote_id}") token_record = { "id": remote_id, "value": args.value or "", "server": server_record_from_info(server_info), "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"id\t{remote_id}") print(f"value\t{'set' if token_record['value'] else 'empty'}") print(f"remote\t{remote_id}") print(f"status\t{'dry-run' if args.dry_run else 'added'}") if args.dry_run: return token_data.setdefault("tokens", []).append(token_record) write_token_store(config, token_data) def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_repo_argument(args.repo) repo_root = git_repo_root(repo_path) if repo_root is None: raise SystemExit(f"Missing git repo at path: {repo_path}") if not args.remote: raise SystemExit("Pass --remote for tokens write.") token_data = load_token_store(config) existing_remote_url = git_capture(repo_root, ["remote", "get-url", args.remote]) target_url = args.url or existing_remote_url target_endpoint = None if target_url: target_server_info = server_info_from_url(config, target_url) if target_server_info is None: raise SystemExit(f"Only http/https remote URLs are supported: {target_url}") target_endpoint = target_server_info["endpoint"] token_record = resolve_token_from_store( token_data, args.server or target_endpoint, args.token_name or args.remote, args.user, ) server_endpoint = token_server_endpoint(token_record) 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}:{remote_id}") if not user_name: raise SystemExit(f"Token user is empty in tokens.json: {server_endpoint}:{remote_id}") if not target_url: target_url = remote_url_from_token_record(token_record) if not target_url: raise SystemExit( f"Missing target remote URL and tokens.json has no org/repo for: {server_endpoint}:{remote_id}" ) rename_candidate = None if getattr(args, "auto_rename_origin", False) and existing_remote_url is None: rename_candidate = find_origin_rename_candidate(config, repo_root, args.remote, target_url) if rename_candidate: _, existing_remote_url = rename_candidate 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 == (user_name, token_value): status = "renamed" if rename_candidate else "unchanged" elif existing_credentials is not None and not args.replace: raise SystemExit( f"Remote '{args.remote}' already has different credentials. " "Pass --replace to overwrite them." ) print(f"repo_root\t{repo_root}") print(f"remote\t{args.remote}") if rename_candidate: print(f"renamed_from\t{rename_candidate[0]}") print(f"server\t{server_endpoint}") 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]}") if args.dry_run or status == "unchanged": return if rename_candidate: subprocess.run( ["git", "-C", str(repo_root), "remote", "rename", rename_candidate[0], args.remote], check=True, ) set_git_remote(repo_root, args.remote, final_url) 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) return repo_path = resolve_repo_argument(args.repo) report = sync_tokens_from_repo(config, repo_path) repo_root, repo_servers = collect_repo_server_entries(config, repo_path) token_data = load_token_store(config, write_normalized=False) store_servers = store_servers_from_tokens(token_data) endpoint_names = sorted(set(repo_servers) | set(store_servers)) print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) return if args.from_source == "store": run_tokens_write(config, args) return 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: branch_name = git_capture(repo_path, ["branch", "--show-current"]) if branch_name: return branch_name head_ref = git_capture(repo_path, ["symbolic-ref", "--short", f"refs/remotes/{config.git.origin_remote}/HEAD"]) if head_ref and "/" in head_ref: return head_ref.split("/", 1)[1] return config.git.fallback_branch def fallback_source_repo_name(series_name: str, card_name: str) -> str: return "-".join([slug_value(series_name, "series"), slug_value(card_name, "card")]) def fallback_source_url(config: WorkspaceConfig, series_name: str, card_name: str) -> str: repo_name = fallback_source_repo_name(series_name, card_name) return f"{config.git.base_url}/{config.git.source_org}/{repo_name}.git" def discover_source_url(config: WorkspaceConfig, repo_path: Path, series_name: str, card_name: str) -> str: source_url = git_capture(repo_path, ["remote", "get-url", config.git.origin_remote]) if source_url: return source_url source_url = git_capture(repo_path, ["remote", "get-url", config.git.source_remote]) if source_url: return source_url return fallback_source_url(config, series_name, card_name) def repo_name_from_url(remote_url: str) -> str: repo_name = remote_url.rstrip("/").rsplit("/", 1)[-1] if repo_name.endswith(".git"): repo_name = repo_name[:-4] return slug_value(repo_name, "source repo") def build_answer_repo_name(source_repo_name: str, class_name: str, date_value: str) -> str: return source_repo_name def build_answer_url(config: WorkspaceConfig, answer_repo_name: str) -> str: return f"{config.git.base_url}/{config.git.answer_org}/{answer_repo_name}.git" def answer_url_for_card(config: WorkspaceConfig, repo_name: str) -> str: return f"{config.git.base_url}/{config.git.answer_org}/{repo_name}.git" def set_git_remote(repo_path: Path, remote_name: str, remote_url: str) -> str: existing_url = git_capture(repo_path, ["remote", "get-url", remote_name]) if existing_url == remote_url: return "unchanged" if existing_url: subprocess.run( ["git", "-C", str(repo_path), "remote", "set-url", remote_name, remote_url], check=True, ) return "updated" subprocess.run( ["git", "-C", str(repo_path), "remote", "add", remote_name, remote_url], check=True, ) return "added" def source_url_for_card(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> str: source_url = git_capture(source_card_path, ["remote", "get-url", config.git.source_remote]) if source_url: return source_url source_url = git_capture(source_card_path, ["remote", "get-url", config.git.origin_remote]) if source_url: return source_url return f"{config.git.base_url}/{config.git.source_org}/{source_card_path.name}.git" def source_url_for_card_info(config: WorkspaceConfig, card_info: CardInfo) -> str: card_config = config_for_card(config, card_info) if card_info.source_path: return source_url_for_card(card_config, card_info.series_id, card_info.source_path) return f"{card_config.git.base_url}/{card_info.source_org}/{card_info.repo}.git" def ensure_answer_token_from_source( config: WorkspaceConfig, source_remote: str, answer_remote: str, answer_url: str, dry_run: bool = False, ) -> tuple[str, dict]: token_data = load_token_store(config, write_normalized=not dry_run) answer_server_info = server_info_from_url(config, answer_url) if answer_server_info is None: raise SystemExit(f"Only http/https remote URLs are supported: {answer_url}") source_token = resolve_token_from_store(token_data, answer_server_info["endpoint"], source_remote, None) if dry_run: return "dry-run", source_token status = register_token( token_data, answer_server_info, answer_remote, str(source_token.get("user", "")), str(source_token.get("value", "")), hydrate=False, reset_api_metadata=True, ) write_token_store(config, token_data) return status, source_token def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path: return config.series_root / series_name / source_card_path.name def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path: return config.series_root / card_info.series_id / card_info.repo def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: card_info = resolve_card_info(config, series_name, card_name) card_path = workspace_card_path_for_info(config, card_info) if not card_path.is_dir(): raise SystemExit(f"Missing workspace card: {card_path}. Run: ./rvctl series cards fetch {series_name} {card_name}") return card_path def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name: str, dry_run: bool = False) -> dict[str, str]: card_info = resolve_card_info(config, series_name, card_name) card_config = config_for_card(config, card_info) repo_name = card_info.repo target_path = workspace_card_path_for_info(config, card_info) source_url = source_url_for_card_info(config, card_info) answer_url = answer_url_for_card(card_config, repo_name) source_branch = card_info.branch if not dry_run: target_path.parent.mkdir(parents=True, exist_ok=True) if target_path.exists() and git_repo_root(target_path) is None: raise SystemExit(f"Workspace card path exists but is not a git repo: {target_path}") cloned = False if not target_path.exists(): subprocess.run(["git", "clone", source_url, str(target_path)], check=True) cloned = True origin_url = git_capture(target_path, ["remote", "get-url", card_config.git.origin_remote]) if origin_url and git_capture(target_path, ["remote", "get-url", card_config.git.source_remote]) is None: subprocess.run( ["git", "-C", str(target_path), "remote", "rename", card_config.git.origin_remote, card_config.git.source_remote], check=True, ) set_git_remote(target_path, card_config.git.source_remote, source_url) subprocess.run(["git", "-C", str(target_path), "fetch", card_config.git.source_remote, source_branch], check=True) current_branch = git_current_branch(card_config, target_path) if cloned or current_branch == source_branch: if current_branch != source_branch: local_branch_exists = subprocess.run( ["git", "-C", str(target_path), "rev-parse", "--verify", "--quiet", source_branch], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ).returncode == 0 if local_branch_exists: subprocess.run(["git", "-C", str(target_path), "switch", source_branch], check=True) else: subprocess.run( ["git", "-C", str(target_path), "switch", "--track", "-c", source_branch, f"{card_config.git.source_remote}/{source_branch}"], check=True, ) subprocess.run(["git", "-C", str(target_path), "pull", "--ff-only", card_config.git.source_remote, source_branch], check=True) token_status, source_token = ensure_answer_token_from_source( card_config, card_config.git.source_remote, card_config.git.answer_remote, answer_url, dry_run=dry_run, ) answer_remote_status = "dry-run" if not dry_run: answer_remote_url = credentialed_remote_url( answer_url, str(source_token.get("user", "")), str(source_token.get("value", "")), ) answer_remote_status = set_git_remote(target_path, card_config.git.answer_remote, answer_remote_url) return { "series": series_name, "card": card_info.card_id, "repo": repo_name, "source": source_url, "workspace": str(target_path), "source_remote": card_config.git.source_remote, "source_branch": source_branch, "answer_remote": card_config.git.answer_remote, "answer_org": card_config.git.answer_org, "answer_repo": repo_name, "answer_url": answer_url, "token": card_config.git.answer_remote, "token_status": token_status, "answer_remote_status": answer_remote_status, "status": "dry-run" if dry_run else "ready", } def print_key_values(values: dict[str, str]) -> None: for key, value in values.items(): print(f"{key}\t{value}") def task_name_from_path(path: Path) -> str: if path.is_file(): return path.stem return path.name def task_entries(card_path: Path) -> list[TaskEntry]: entries: dict[str, TaskEntry] = {} task_root = card_path / "src" / "tasks" if task_root.is_dir(): for path in sorted(task_root.iterdir()): name = task_name_from_path(path) if (path.is_file() or path.is_dir()) and name.startswith("task"): entries[name] = TaskEntry(name=name, path=path) for path in sorted(card_path.iterdir()): name = task_name_from_path(path) if (path.is_file() or path.is_dir()) and name.startswith("task"): entries.setdefault(name, TaskEntry(name=name, path=path)) return [entries[name] for name in sorted(entries)] def task_names(card_path: Path) -> list[str]: return [entry.name for entry in task_entries(card_path)] def task_branch_part(task_name: str) -> str: match = re.search(r"(?i)task[-_]?(\d+)", task_name) if match is None: match = re.fullmatch(r"(?i)t(\d+)", task_name.strip()) if match is None: match = re.search(r"(\d+)", task_name) if match is None: raise SystemExit(f"Task name does not contain a task number: {task_name}") return f"T{int(match.group(1))}" def resolve_submission_task(config: WorkspaceConfig, card_path: Path, task_arg: str | None) -> str: task_selector = task_arg or config.defaults.get("task") if not task_selector: raise SystemExit("Missing task selector. Pass --task or set defaults.task in workspace.json.") names = task_names(card_path) if not names: return task_selector matches = [name for name in names if name == task_selector] if not matches: try: selector_part = task_branch_part(task_selector) except SystemExit: selector_part = "" number_matches = [] for name in names: try: if selector_part and task_branch_part(name) == selector_part: number_matches.append(name) except SystemExit: pass matches = number_matches if not matches: matches = [name for name in names if task_selector in name] if not matches: raise SystemExit(f"Missing task '{task_selector}' in card: {card_path}") if len(matches) > 1: raise SystemExit(f"Ambiguous task '{task_selector}'. Matches: {', '.join(matches)}") return matches[0] def store_user_for_submission( config: WorkspaceConfig, source_url: str, answer_url: str, required: bool = True, ) -> str: token_data = load_token_store(config) endpoints = [] for remote_url in [source_url, answer_url, config.git.base_url]: server_info = server_info_from_url(config, remote_url) if server_info is None: continue endpoint = server_info["endpoint"] if endpoint not in endpoints: endpoints.append(endpoint) for endpoint in endpoints: for remote_id in [config.git.source_remote, config.git.answer_remote]: token_record = find_token_record(token_data, endpoint, remote_id) if token_record and token_record.get("user"): return slug_value(str(token_record["user"]), "token user") users = { str(token_record.get("user", "")) for token_record in token_data.get("tokens", []) if token_server_endpoint(token_record) in endpoints and token_record.get("user") } if len(users) == 1: return slug_value(next(iter(users)), "token user") if required: raise SystemExit("Missing --nick and no unique user in tokens.json for the answer branch.") return "" def submission_branch_name(user_name: str, task_name: str) -> str: return branch_value(f"{slug_value(user_name, 'student user')}{task_branch_part(task_name)}a") def set_branch_upstream(repo_path: Path, branch_name: str, remote_name: str, dry_run: bool = False) -> str: expected_merge = f"refs/heads/{branch_name}" current_remote = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.remote"]) current_merge = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.merge"]) if current_remote == remote_name and current_merge == expected_merge: return "unchanged" if dry_run: return "dry-run" subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.remote", remote_name], check=True) subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.merge", expected_merge], check=True) return "updated" if current_remote or current_merge else "set" def print_card_tasks(config: WorkspaceConfig, series_name: str, card_name: str) -> None: card_path = resolve_workspace_card_path(config, series_name, card_name) for entry in task_entries(card_path): print(f"{series_name}\t{card_label_from_repo_name(card_path.name)}\t{entry.name}\t{entry.path}") def print_card_task(config: WorkspaceConfig, series_name: str, card_name: str, task_name: str) -> None: card_path = resolve_workspace_card_path(config, series_name, card_name) matches = [entry for entry in task_entries(card_path) if entry.name == task_name or task_name in entry.name] if not matches: raise SystemExit(f"Missing task '{task_name}' in card: {card_path}") if len(matches) > 1: raise SystemExit(f"Ambiguous task '{task_name}'. Matches: {', '.join(entry.name for entry in matches)}") task_entry = matches[0] print(f"series\t{series_name}") print(f"card\t{card_label_from_repo_name(card_path.name)}") print(f"task\t{task_entry.name}") print(f"card_path\t{card_path}") print(f"task_path\t{task_entry.path}") def switch_card_task( config: WorkspaceConfig, series_name: str, card_name: str, task_selector: str, branch_override: str | None = None, dry_run: bool = False, ) -> dict[str, str]: card_info = resolve_card_info(config, series_name, card_name) card_config = config_for_card(config, card_info) card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_selector) source_url = discover_source_url(card_config, card_path, series_name, card_name) source_repo_name = repo_name_from_url(source_url) answer_url = build_answer_url(card_config, source_repo_name) student_user = store_user_for_submission(card_config, source_url, answer_url, required=branch_override is None) branch_name = branch_value(branch_override) if branch_override else submission_branch_name(student_user, task_name) current_branch = git_current_branch(card_config, card_path) branch_exists = subprocess.run( ["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ).returncode == 0 token_status = "dry-run" answer_remote_status = "dry-run" if not dry_run: token_status, source_token = ensure_answer_token_from_source( card_config, card_config.git.source_remote, card_config.git.answer_remote, answer_url, ) answer_remote_status = set_git_remote( card_path, card_config.git.answer_remote, credentialed_remote_url( answer_url, str(source_token.get("user", "")), str(source_token.get("value", "")), ), ) if dry_run: status = "dry-run" elif current_branch == branch_name: status = "unchanged" elif branch_exists: subprocess.run(["git", "-C", str(card_path), "switch", branch_name], check=True) status = "switched" else: subprocess.run(["git", "-C", str(card_path), "switch", "-c", branch_name], check=True) status = "created" upstream_status = set_branch_upstream(card_path, branch_name, card_config.git.answer_remote, dry_run=dry_run) return { "series": series_name, "card": card_info.card_id, "task": task_name, "student_user": student_user, "branch": branch_name, "answer_remote": card_config.git.answer_remote, "answer_url": answer_url, "token_status": token_status, "answer_remote_status": answer_remote_status, "upstream": f"{card_config.git.answer_remote}/{branch_name}", "upstream_status": upstream_status, "workspace": str(card_path), "status": status, } def print_series_details(config: WorkspaceConfig, series_name: str) -> None: config.series_root.mkdir(parents=True, exist_ok=True) source_path = config.original_series_root / series_name workspace_path = config.series_root / series_name cards = source_card_infos(config, series_name) if not cards and not workspace_path.is_dir(): raise SystemExit(f"Missing series: {series_name}") print(f"series\t{series_name}") print(f"source_path\t{source_path if source_path.is_dir() else ''}") print(f"manifest\t{config.workspace_info_path if workspace_series_manifest(config, series_name) else ''}") print(f"workspace_path\t{workspace_path}") print(f"cards_count\t{len(cards)}") print(f"cards\t{' '.join(card.card_id for card in cards)}") def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str) -> None: card_info = resolve_card_info(config, series_name, card_name) card_config = config_for_card(config, card_info) target_path = workspace_card_path_for_info(config, card_info) repo_name = card_info.repo print(f"series\t{series_name}") print(f"card\t{card_info.card_id}") print(f"repo\t{repo_name}") print(f"source_path\t{card_info.source_path or ''}") print(f"workspace_path\t{target_path}") print(f"source_remote\t{card_config.git.source_remote}") print(f"source_url\t{source_url_for_card_info(config, card_info)}") print(f"source_branch\t{card_info.branch}") print(f"answer_remote\t{card_config.git.answer_remote}") print(f"answer_url\t{answer_url_for_card(card_config, repo_name)}") if target_path.is_dir(): print(f"tasks\t{' '.join(task_names(target_path))}") def available_series_hint(config: WorkspaceConfig) -> str: names = sorted( set(workspace_series_manifests(config)) | {path.name for path in source_series_directories(config)} | {path.name for path in series_directories(config)} ) return ", ".join(names) def use_series_default(config: WorkspaceConfig, series_name: str) -> dict[str, str]: series_id = slug_value(series_name, "series") if not series_exists(config, series_id): hint = available_series_hint(config) suffix = f" Available: {hint}" if hint else "" raise SystemExit(f"Missing series '{series_name}'.{suffix}") return update_config_defaults(config, {"series": series_id}) def use_card_default(config: WorkspaceConfig, selector: list[str]) -> dict[str, str]: if len(selector) == 1: series_name = config.defaults.get("series") if not series_name: raise SystemExit("Missing default series. Use: ./rvctl series use inf") card_name = selector[0] updates_series = False elif len(selector) == 2: series_name = slug_value(selector[0], "series") card_name = selector[1] updates_series = True else: raise SystemExit("Use: ./rvctl card use or ./rvctl card use ") if not series_exists(config, series_name): hint = available_series_hint(config) suffix = f" Available: {hint}" if hint else "" raise SystemExit(f"Missing series '{series_name}'.{suffix}") card_info = resolve_card_info(config, series_name, card_name) updates = {"card": card_info.card_id} if updates_series: updates = {"series": series_name, "card": card_info.card_id} return update_config_defaults(config, updates) def run_series(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.series_command == "list": print_series(config) return if args.series_command == "use": print_key_values(use_series_default(config, args.series)) return if args.series_command == "show": print_series_details(config, args.series) return if args.series_command == "fetch": for card_info in source_card_infos(config, args.series): result = fetch_card_to_workspace(config, args.series, card_info.card_id, dry_run=args.dry_run) print_key_values(result) print() return if args.series_command != "cards": raise SystemExit(f"Unsupported series command: {args.series_command}") if args.cards_command == "list": print_cards(config, args.series) return if args.cards_command == "show": print_card_details(config, args.series, args.card) return if args.cards_command == "fetch": print_key_values(fetch_card_to_workspace(config, args.series, args.card, dry_run=args.dry_run)) return if args.cards_command == "submission": print_submission_plan(config, args) return if args.cards_command != "tasks": raise SystemExit(f"Unsupported series cards command: {args.cards_command}") if args.tasks_command == "list": print_card_tasks(config, args.series, args.card) return if args.tasks_command == "show": print_card_task(config, args.series, args.card, args.task) return if args.tasks_command == "switch": print_key_values(switch_card_task(config, args.series, args.card, args.task, args.branch, args.dry_run)) return raise SystemExit(f"Unsupported series cards tasks command: {args.tasks_command}") def run_card(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.card_command == "use": print_key_values(use_card_default(config, args.selector)) return raise SystemExit(f"Unsupported card command: {args.card_command}") def resolve_task_command_selector( config: WorkspaceConfig, values: list[str], needs_task: bool = False, ) -> tuple[str, str, str | None]: if not needs_task: if len(values) > 2: raise SystemExit("Pass at most ' ' for tasks list.") series_arg = values[0] if values else None card_arg = values[1] if len(values) == 2 else None series_name, card_name = resolve_selector(config, series_arg, card_arg) return series_name, card_name, None if not values: raise SystemExit("Missing task. Use: ./rvctl tasks switch 4") if len(values) > 3: raise SystemExit("Pass '', ' ' or ' '.") if len(values) == 1: series_name, card_name = resolve_selector(config, None, None) return series_name, card_name, values[0] if len(values) == 2: series_name, card_name = resolve_selector(config, values[0], None) return series_name, card_name, values[1] series_name, card_name = resolve_selector(config, values[0], values[1]) return series_name, card_name, values[2] def run_tasks(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.tasks_command == "list": series_name, card_name, _ = resolve_task_command_selector(config, args.selector) print_card_tasks(config, series_name, card_name) return if args.tasks_command == "show": series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True) print_card_task(config, series_name, card_name, task_name or "") return if args.tasks_command == "switch": series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True) print_key_values(switch_card_task(config, series_name, card_name, task_name or "", args.branch, args.dry_run)) return raise SystemExit(f"Unsupported tasks command: {args.tasks_command}") def print_submission_plan(config: WorkspaceConfig, args: argparse.Namespace) -> None: series_name, card_name = resolve_selector(config, args.series, args.card) card_info = resolve_card_info(config, series_name, card_name) card_config = config_for_card(config, card_info) selector = f"{series_name}/{card_name}" card_path = resolve_card_path(config, series_name, card_name) sync_tokens_from_repo(card_config, card_path) class_name = slug_value(args.class_name, "class") source_url = args.source_url or discover_source_url(card_config, card_path, series_name, card_name) source_repo_name = repo_name_from_url(source_url) answer_repo_name = build_answer_repo_name(source_repo_name, class_name, args.date) answer_url = build_answer_url(card_config, answer_repo_name) source_branch = git_current_branch(card_config, card_path) task_name = resolve_submission_task(config, card_path, args.task) student_user = ( slug_value(args.nick, "student user") if args.nick else store_user_for_submission(card_config, source_url, answer_url, required=args.branch is None) ) branch_name = branch_value(args.branch) if args.branch else submission_branch_name(student_user, task_name) print(f"selector\t{selector}") print(f"card_path\t{card_path}") print(f"task\t{task_name}") print(f"source_repo\t{card_config.git.source_org}/{source_repo_name}") print(f"source_remote\t{card_config.git.source_remote}") print(f"source_url\t{source_url}") print(f"source_branch\t{source_branch}") print(f"answer_remote\t{card_config.git.answer_remote}") print(f"answer_repo\t{card_config.git.answer_org}/{answer_repo_name}") print(f"answer_url\t{answer_url}") print(f"student_user\t{student_user}") print(f"student_branch\t{branch_name}") if args.apply: source_status = set_git_remote(card_path, card_config.git.source_remote, source_url) token_status, source_token = ensure_answer_token_from_source( card_config, card_config.git.source_remote, card_config.git.answer_remote, answer_url, ) answer_status = set_git_remote( card_path, card_config.git.answer_remote, credentialed_remote_url( answer_url, str(source_token.get("user", "")), str(source_token.get("value", "")), ), ) print(f"apply_{card_config.git.source_remote}\t{source_status}") print(f"apply_token_{card_config.git.answer_remote}\t{token_status}") print(f"apply_{card_config.git.answer_remote}\t{answer_status}") print("commands") print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.source_remote} {shlex.quote(source_url)}") print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.answer_remote} {shlex.quote(answer_url)}") print(f" git -C {shlex.quote(str(card_path))} fetch {card_config.git.source_remote} {shlex.quote(source_branch)}") print(f" git -C {shlex.quote(str(card_path))} switch -c {shlex.quote(branch_name)}") print(f" git -C {shlex.quote(str(card_path))} push -u {card_config.git.answer_remote} {shlex.quote(branch_name)}") def tmux_target_exists(session_name: str) -> bool: result = subprocess.run( ["tmux", "has-session", "-t", session_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) return result.returncode == 0 def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> None: series_name, card_name = resolve_selector(config, args.series, args.card) selector = f"{series_name}/{card_name}" card_path = resolve_card_path(config, series_name, card_name) profile = normalize_env_profile(args.profile) task_name = resolve_submission_task(config, card_path, None) instance = args.instance or default_env_instance(profile, series_name, card_name, task_name, "shell") window_name = args.window or config.defaults.get("tmux_window", "rv") session_name = args.session or slug_value(f"stem-{instance}", "tmux session") if tmux_target_exists(session_name): raise SystemExit( f"tmux session already exists: {session_name}\n" "Use another --session name or remove the existing session first." ) # Compatibility command: keep the outer host tmux session, but delegate # container creation/mounts/labels entirely to the canonical rootless # ./stem runtime. Do not maintain a second Docker Compose lifecycle here. shell_command, details = env_action_command( config, profile, "shell", series_name=series_name, card_name=card_name, task_name=task_name, instance=instance, ) tmux_command = [ "tmux", "new-session", "-d", "-s", session_name, "-n", window_name, f"bash -lc {shlex.quote(shell_command)}", ] if args.dry_run: print("Selector:", selector) print("Card path:", card_path) print("Tools root:", config.tools_root) print("Runtime: rootless stem/Podman") print("Instance:", instance) print("Pane 0 command:") print(shell_command) print("tmux command:") print(shlex.join(tmux_command)) print_env_plan(details) return subprocess.run(tmux_command, check=True) if args.attach: subprocess.run(["tmux", "attach", "-t", session_name], check=True) return print(f"Created tmux session: {session_name}") print(f"Window: {window_name}") print("Pane 0: container shell") print(f"Attach with: tmux attach -t {session_name}") def normalize_env_profile(profile: str) -> str: normalized = ENV_PROFILE_ALIASES.get(profile, profile) if normalized not in ENV_PROFILES: available = ", ".join(sorted(ENV_PROFILES)) raise SystemExit(f"Unsupported environment profile '{profile}'. Available: {available}") return normalized def env_tool_command_path(config: WorkspaceConfig) -> Path: stem_script = config.tools_root / "stem" return stem_script if stem_script.is_file() else config.tools_root / "rv" def ensure_env_tool(config: WorkspaceConfig) -> Path: env_script = env_tool_command_path(config) if env_script.is_file(): return env_script candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates) raise SystemExit( "Missing environment launcher script 'stem' (or compatible 'rv').\n" f"Checked:\n{candidates}\n" "Run: ./stemctl env sync" ) def env_sync_target(config: WorkspaceConfig) -> Path: return config.workspace_root / "tools" / "rv32i-hazard3-student-env" def sync_env_tool(config: WorkspaceConfig, dry_run: bool = False) -> dict[str, str]: target = env_sync_target(config) if dry_run: command = "pull" if (target / ".git").is_dir() else "clone" return { "env_tool_path": str(target), "env_tool_url": config.env_tool_url, "command": command, "status": "dry-run", } target.parent.mkdir(parents=True, exist_ok=True) if (target / ".git").is_dir(): subprocess.run(["git", "-C", str(target), "pull", "--ff-only"], check=True) config.tools_root = target return { "env_tool_path": str(target), "env_tool_url": config.env_tool_url, "command": "pull", "status": "ready", } if target.exists(): raise SystemExit(f"Environment tool path exists but is not a git repo: {target}") subprocess.run(["git", "clone", config.env_tool_url, str(target)], check=True) config.tools_root = target return { "env_tool_path": str(target), "env_tool_url": config.env_tool_url, "command": "clone", "status": "ready", } def task_selector_like(value: str) -> bool: return bool(re.fullmatch(r"(?i)(task[-_]?)?\d+.*|t\d+", value.strip())) def resolve_env_target( config: WorkspaceConfig, values: list[str], task_override: str | None = None, ) -> tuple[str, str, str]: if len(values) > 3: raise SystemExit("Pass '', ' [task]' or ' [task]'.") if not values: series_name, card_name = resolve_selector(config, None, None) card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_override) return series_name, card_name, task_name if len(values) == 1: if task_selector_like(values[0]): series_name, card_name = resolve_selector(config, None, None) task_selector = task_override or values[0] else: series_name, card_name = resolve_selector(config, values[0], None) task_selector = task_override card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_selector) return series_name, card_name, task_name if len(values) == 2: if task_selector_like(values[1]): series_name, card_name = resolve_selector(config, values[0], None) task_selector = task_override or values[1] else: series_name, card_name = resolve_selector(config, values[0], values[1]) task_selector = task_override card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_selector) return series_name, card_name, task_name series_name, card_name = resolve_selector(config, values[0], values[1]) card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_override or values[2]) return series_name, card_name, task_name def split_pane_selector(values: list[str], explicit_pane: str | None = None) -> tuple[list[str], str | None]: if "pane" not in values: return values, explicit_pane if explicit_pane: raise SystemExit("Pass pane target either as '--pane TARGET' or as 'pane TARGET', not both.") index = values.index("pane") pane_values = values[index + 1 :] if len(pane_values) != 1: raise SystemExit("Use: pane TARGET, for example 'pane 0' or 'pane node:0'.") return values[:index], pane_values[0] def tmux_pane_target(raw_target: str) -> str: target = raw_target.strip() if not target: raise SystemExit("Empty tmux pane target.") if re.fullmatch(r"\d+", target): return f":.{target}" if re.fullmatch(r"%\d+", target): return target window_pane_match = re.fullmatch(r"([^:.]+):(\d+)", target) if window_pane_match: window_name, pane_index = window_pane_match.groups() return f":{window_name}.{pane_index}" return target def send_command_to_tmux_pane(shell_command: str, raw_target: str, dry_run: bool = False) -> dict[str, str]: target = tmux_pane_target(raw_target) details = { "pane": raw_target, "tmux_target": target, "tmux_send": f"tmux send-keys -t {shlex.quote(target)} -l {shlex.quote(shell_command)}; tmux send-keys -t {shlex.quote(target)} C-m", } if dry_run: return details resolved = subprocess.run( ["tmux", "display-message", "-p", "-t", target, "#{session_name}:#{window_name}.#{pane_index}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) if resolved.returncode != 0: raise SystemExit(f"Missing tmux pane target '{raw_target}' ({target}): {resolved.stderr.strip()}") subprocess.run(["tmux", "send-keys", "-t", target, "-l", shell_command], check=True) subprocess.run(["tmux", "send-keys", "-t", target, "C-m"], check=True) details["tmux_resolved"] = resolved.stdout.strip() return details def default_env_instance(profile: str, series_name: str, card_name: str, task_name: str, action: str) -> str: task_part = task_branch_part(task_name).lower() del action return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}", "environment instance") def env_action_command( config: WorkspaceConfig, profile: str, action: str, series_name: str | None = None, card_name: str | None = None, task_name: str | None = None, instance: str | None = None, editor: str | None = None, target: str | None = None, device: str | None = None, deploy_backend: str | None = None, allow_existing_firmware: bool = False, ) -> tuple[str, dict[str, str]]: profile = normalize_env_profile(profile) env_script = ensure_env_tool(config) selected_target = target or PROFILE_DEFAULT_TARGETS[profile] env_vars = { "STEM_WORKSPACE_ROOT": str(config.workspace_root), "STEM_CARDS_ROOT": str(config.series_root), "STEM_SOCKET_ROOT": str(config.socket_root), "STEM_PROFILE": profile, "STEM_TARGET": selected_target, "WORKSPACE_ROOT": str(config.workspace_root), "RV_CARDS_ROOT": str(config.series_root), "RV_PROFILE": profile, } command_args = ["bash", f"./{env_script.name}"] card_path: Path | None = None selector = "" if series_name and card_name: card_path = resolve_workspace_card_path(config, series_name, card_name) selector = f"{series_name}/{card_name}" env_vars.update( { "STEM_REPO_PATH": str(card_path), "STEM_REPO_HOST": str(card_path), "STEM_CARD": card_path.name, "RV_REPO_PATH": str(card_path), "RV_REPO_HOST": str(card_path), "RV_CARD": card_path.name, } ) if instance: env_vars["STEM_INSTANCE"] = instance env_vars["RV_INSTANCE"] = instance elif series_name and card_name and task_name: generated_instance = default_env_instance(profile, series_name, card_name, task_name, action) env_vars["STEM_INSTANCE"] = generated_instance env_vars["RV_INSTANCE"] = generated_instance if editor: env_vars["STEM_EDITOR"] = editor env_vars["RV_EDITOR"] = editor if device: env_vars["STEM_PROBE_DEVICE"] = device if deploy_backend: env_vars["STEM_DEPLOY_BACKEND"] = deploy_backend if action in {"build", "test", "run", "debug", "deploy"}: if not task_name: raise SystemExit(f"{action} requires a task selector.") command_args.extend([action, "--target", selected_target, task_name]) if device: command_args.extend(["--device", device]) if action == "deploy" and deploy_backend: command_args.extend(["--backend", deploy_backend]) if allow_existing_firmware: command_args.append("--allow-existing-firmware") elif action in {"shell", "start", "status", "attach", "stop", "rm"}: command_args.extend([action, "--target", selected_target]) else: raise SystemExit(f"Unsupported environment action: {action}") env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env_vars.items()) shell_command = " && ".join( [ f"cd {shlex.quote(str(env_script.parent))}", " ".join([env_prefix, *[shlex.quote(part) for part in command_args]]), ] ) details = { "profile": profile, "service": ENV_PROFILES[profile]["service"], "target": selected_target, "selector": selector, "task": task_name or "", "card_path": str(card_path or ""), "tools_root": str(config.tools_root), "instance": env_vars.get("RV_INSTANCE", ""), "device": device or "", "backend": deploy_backend or "", "command": shell_command, } return shell_command, details def print_env_plan(details: dict[str, str]) -> None: keys = [ "profile", "service", "target", "selector", "task", "card_path", "tools_root", "instance", "device", "backend", "pane", "tmux_target", "tmux_resolved", "tmux_send", "command", ] for key in keys: if key == "command" or details.get(key): print(f"{key}\t{details.get(key, '')}") def run_env(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.env_command == "list": print("profile\tservice\timage\tstatus\tpurpose") for profile, info in ENV_PROFILES.items(): print( f"{profile}\t{info['service']}\t{info['image']}\t" f"{'ready' if env_tool_command_path(config).is_file() else 'missing'}\t{info['purpose']}" ) print(f"tools_root\t{config.tools_root}") print(f"env_tool_url\t{config.env_tool_url}") return if args.env_command == "sync": print_key_values(sync_env_tool(config, dry_run=args.dry_run)) return if args.env_command == "sources": series_name, card_name = resolve_selector(config, args.series, args.card) card_path = resolve_workspace_card_path(config, series_name, card_name) env_script = ensure_env_tool(config) preparer = env_script.parent / "scripts" / "prepare-workspace-sources.py" if not preparer.is_file(): raise SystemExit( f"Environment tool does not provide pinned workspace sources: {preparer}. " "Run: ./stemctl env sync" ) mode = "check" if args.check else "prepare" command = [str(preparer), mode, str(card_path)] if args.dry_run: print(f"series\t{series_name}") print(f"card\t{card_name}") print(f"workspace\t{card_path}") print(f"command\t{shlex.join(command)}") return subprocess.run(command, check=True) return if args.env_command == "build": profile = normalize_env_profile(args.profile) env_script = ensure_env_tool(config) shell_command = " && ".join( [ f"cd {shlex.quote(str(env_script.parent))}", " ".join( [ f"STEM_PROFILE={shlex.quote(profile)}", f"STEM_WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}", f"STEM_SOCKET_ROOT={shlex.quote(str(config.socket_root))}", "bash", shlex.quote(f"./{env_script.name}"), "profile-build", shlex.quote(profile), ] ), ] ) if args.dry_run: print(f"profile\t{profile}") print(f"command\t{shell_command}") return subprocess.run(["bash", "-lc", shell_command], check=True) return if args.env_command == "ensure": profile = normalize_env_profile(args.profile) env_script = ensure_env_tool(config) env_vars = os.environ.copy() env_vars.update( { "STEM_WORKSPACE_ROOT": str(config.workspace_root), "STEM_SOCKET_ROOT": str(config.socket_root), "STEM_PROFILE": profile, } ) command = ["bash", str(env_script), "ensure", profile] if args.dry_run: print(f"profile\t{profile}") print(f"command\t{shlex.join(command)}") return subprocess.run(command, cwd=env_script.parent, env=env_vars, check=True) return raise SystemExit(f"Unsupported env command: {args.env_command}") def run_probe(config: WorkspaceConfig, args: argparse.Namespace) -> None: env_script = ensure_env_tool(config) command = ["bash", str(env_script), "probe", "list"] if args.json: command.append("--json") env = os.environ.copy() env.update( { "STEM_WORKSPACE_ROOT": str(config.workspace_root), "STEM_SOCKET_ROOT": str(config.socket_root), "STEM_PROFILE": "rp2350", } ) subprocess.run(command, cwd=env_script.parent, env=env, check=True) def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None: """Manage the dynamic MCP target or serve one explicitly bound instance.""" if args.mcp_kind == "list": print_mcp_instances(config) return if args.mcp_kind == "select": select_mcp_instance(config, args.selector) return if args.mcp_kind == "status": print_mcp_selection(config) return env_script = ensure_env_tool(config) wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh" if not wrapper.is_file(): raise SystemExit(f"Missing MCP wrapper in environment sources: {wrapper}") env = os.environ.copy() env.update( { "STEM_WORKSPACE_ROOT": str(config.workspace_root), "STEM_SOCKET_ROOT": str(config.socket_root), } ) os.execvpe(str(wrapper), [str(wrapper), args.instance], env) def mcp_selection_root(config: WorkspaceConfig) -> Path: """Return the short, stable host path used by generic MCP providers.""" return config.socket_root / "mcp-selected" def mcp_registry_records(config: WorkspaceConfig) -> list[dict]: registry_root = config.socket_root / "registry" records: list[dict] = [] for path in sorted(registry_root.glob("*.json")): try: record = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue if isinstance(record, dict): record["_registry_path"] = str(path) records.append(record) return records def is_unix_socket(path: Path) -> bool: try: return stat.S_ISSOCK(path.stat().st_mode) except OSError: return False def probe_mcp_socket_servers(record: dict, nvim_socket: Path, tmux_socket: Path) -> None: runtime = str(record["runtime"]) container_name = str(record["container_name"]) probes = ( ( "Neovim", [runtime, "exec", container_name, "nvim", "--server", str(nvim_socket), "--remote-expr", "1"], ), ( "tmux", [runtime, "exec", container_name, "tmux", "-S", str(tmux_socket), "list-sessions"], ), ) failures: list[str] = [] for name, command in probes: try: result = subprocess.run( command, check=False, capture_output=True, text=True, timeout=3, ) except subprocess.TimeoutExpired: failures.append(f"{name}: connection timed out") continue if result.returncode != 0: detail = (result.stderr or result.stdout).strip().splitlines() failures.append(f"{name}: {detail[-1] if detail else 'connection failed'}") if failures: raise SystemExit("MCP socket files exist, but their servers are not live:\n" + "\n".join(failures)) def validated_mcp_record(record: dict) -> tuple[dict, Path, Path]: required = ("runtime", "container_name", "container_id", "instance_key", "socket_dir") missing = [key for key in required if not str(record.get(key, "")).strip()] if missing: raise SystemExit(f"Incomplete MCP registry ({', '.join(missing)}): {record.get('_registry_path', '?')}") runtime = str(record["runtime"]) container_name = str(record["container_name"]) expected_id = str(record["container_id"]) expected_instance_key = str(record["instance_key"]) if shutil.which(runtime) is None: raise SystemExit(f"Recorded container runtime is unavailable: {runtime}") inspected = subprocess.run( [runtime, "inspect", container_name], check=False, capture_output=True, text=True, ) if inspected.returncode != 0: raise SystemExit(f"Container is not available: {container_name}") try: inspection = json.loads(inspected.stdout)[0] except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error: raise SystemExit(f"Cannot inspect container identity: {container_name}") from error actual_id = str(inspection.get("Id", "")) labels = inspection.get("Config", {}).get("Labels") or {} actual_instance_key = str(labels.get("edu.stem.instance-key", "")) if actual_id != expected_id or actual_instance_key != expected_instance_key: raise SystemExit(f"Stale MCP registry rejected for {container_name} (container identity changed).") socket_dir = Path(str(record["socket_dir"])) nvim_socket = socket_dir / "n.sock" tmux_socket = socket_dir / "t.sock" missing_sockets = [str(path) for path in (nvim_socket, tmux_socket) if not is_unix_socket(path)] if missing_sockets: raise SystemExit("MCP sockets are not live:\n" + "\n".join(missing_sockets)) probe_mcp_socket_servers(record, nvim_socket, tmux_socket) return record, nvim_socket, tmux_socket def resolve_mcp_record(config: WorkspaceConfig, selector: str) -> dict: selector = selector.strip() if not selector: raise SystemExit("MCP selector cannot be empty.") matches: list[dict] = [] for record in mcp_registry_records(config): container_id = str(record.get("container_id", "")) if selector in {str(record.get("instance", "")), str(record.get("container_name", ""))}: matches.append(record) elif len(selector) >= 12 and container_id.startswith(selector): matches.append(record) unique = {str(record.get("container_id", "")): record for record in matches} if not unique: raise SystemExit( f"No registered MCP container matches {selector!r}. " "Use 'stemctl mcp list' to see available instances." ) if len(unique) > 1: names = ", ".join(sorted(str(record.get("instance", "?")) for record in unique.values())) raise SystemExit(f"Ambiguous MCP selector {selector!r}: {names}") return next(iter(unique.values())) def select_mcp_instance(config: WorkspaceConfig, selector: str) -> None: record, nvim_socket, tmux_socket = validated_mcp_record(resolve_mcp_record(config, selector)) container_id = str(record["container_id"]) cid12 = container_id[:12] selection_root = mcp_selection_root(config) target_root = selection_root / "targets" / cid12 target_root.mkdir(parents=True, exist_ok=True) os.chmod(selection_root, 0o700) os.chmod(selection_root / "targets", 0o700) os.chmod(target_root, 0o700) for link, target in ((target_root / "n.sock", nvim_socket), (target_root / "t.sock", tmux_socket)): temporary = link.with_name(f".{link.name}.{os.getpid()}") temporary.unlink(missing_ok=True) temporary.symlink_to(target) os.replace(temporary, link) metadata = { key: record.get(key, "") for key in ("runtime", "container_name", "container_id", "profile", "target", "instance", "instance_key", "thread_key", "socket_dir") } metadata_path = target_root / "selection.json" temporary_metadata = metadata_path.with_name(f".{metadata_path.name}.{os.getpid()}") temporary_metadata.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") os.replace(temporary_metadata, metadata_path) current = selection_root / "current" temporary_current = selection_root / f".current.{os.getpid()}" temporary_current.unlink(missing_ok=True) temporary_current.symlink_to(Path("targets") / cid12) os.replace(temporary_current, current) print(f"instance\t{record.get('instance', '')}") print(f"container\t{record.get('container_name', '')}") print(f"container_id\t{container_id}") print(f"nvim_socket\t{current / 'n.sock'}") print(f"tmux_socket\t{current / 't.sock'}") def print_mcp_selection(config: WorkspaceConfig) -> None: current = mcp_selection_root(config) / "current" metadata_path = current / "selection.json" try: record = json.loads(metadata_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise SystemExit("No dynamic MCP container is selected. Use 'stemctl mcp select INSTANCE'.") from error record, _, _ = validated_mcp_record(record) print(f"instance\t{record.get('instance', '')}") print(f"container\t{record.get('container_name', '')}") print(f"container_id\t{record.get('container_id', '')}") print(f"nvim_socket\t{current / 'n.sock'}") print(f"tmux_socket\t{current / 't.sock'}") def print_mcp_instances(config: WorkspaceConfig) -> None: print("container_id\tinstance\tprofile\tcontainer\tsockets") for record in sorted(mcp_registry_records(config), key=lambda item: str(item.get("instance", ""))): socket_dir = Path(str(record.get("socket_dir", ""))) live = False if is_unix_socket(socket_dir / "n.sock") and is_unix_socket(socket_dir / "t.sock"): try: validated_mcp_record(record) live = True except SystemExit: pass print( f"{str(record.get('container_id', ''))[:12]}\t{record.get('instance', '')}\t" f"{record.get('profile', '')}\t{record.get('container_name', '')}\t" f"{'ready' if live else 'not-ready'}" ) def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None: profile = normalize_env_profile(args.profile) selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None)) instance = getattr(args, "instance", None) instance_only_actions = {"status", "attach", "stop", "rm"} if instance and action in instance_only_actions and not selector_values: # A stable logical instance is sufficient for lifecycle operations on # an existing container. Resolving workspace defaults here would make # the operation depend on the currently selected (or still checked # out) card, even though the environment launcher does not need it. series_name = card_name = task_name = None else: series_name, card_name, task_name = resolve_env_target( config, selector_values, getattr(args, "task", None) ) shell_command, details = env_action_command( config, profile, action, series_name=series_name, card_name=card_name, task_name=task_name, instance=instance, editor=getattr(args, "editor", None), target=getattr(args, "target", None), device=getattr(args, "device", None), deploy_backend=getattr(args, "backend", None), allow_existing_firmware=getattr(args, "allow_existing_firmware", False), ) if pane_target: pane_details = send_command_to_tmux_pane(shell_command, pane_target, dry_run=args.dry_run) details.update(pane_details) if args.dry_run: print_env_plan(details) return print_env_plan(details) return if args.dry_run: print_env_plan(details) return subprocess.run(["bash", "-lc", shell_command], check=True) def workspace_info_status(config: WorkspaceConfig) -> str: info_root = workspace_info_root(config) if (info_root / ".git").is_dir(): return "git" if config.workspace_info_path.is_file(): return "file" return "missing" def sync_workspace_info(config: WorkspaceConfig, pull_existing: bool = True) -> str: info_root = workspace_info_root(config) info_root.parent.mkdir(parents=True, exist_ok=True) if (info_root / ".git").is_dir(): if pull_existing: subprocess.run(["git", "-C", str(info_root), "pull", "--ff-only"], check=True) config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8")) return "pulled" if config.workspace_info_path.is_file(): config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8")) return "existing" raise SystemExit(f"Workspace info repo exists but manifest is missing: {config.workspace_info_path}") if info_root.exists(): raise SystemExit(f"Workspace info path exists but is not a git repo: {info_root}") print(f"workspace-info missing; cloning {config.workspace_info_url}", file=sys.stderr) subprocess.run(["git", "clone", config.workspace_info_url, str(info_root)], check=True) if not config.workspace_info_path.is_file(): raise SystemExit(f"Workspace info clone does not contain manifest: {config.workspace_info_path}") config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8")) return "cloned" def ensure_workspace_info(config: WorkspaceConfig) -> None: if config.workspace_info: return if config.workspace_info_path.is_file(): config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8")) return sync_workspace_info(config, pull_existing=False) def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.workspace_command == "show": print(f"workspace_info_path\t{config.workspace_info_path}") print(f"workspace_info_url\t{config.workspace_info_url}") print(f"status\t{workspace_info_status(config)}") if config.workspace_info: print(f"workspace\t{config.workspace_info.get('workspace', '')}") print(f"series\t{len(config.workspace_info.get('series', []))}") return if args.workspace_command == "sync": info_root = workspace_info_root(config) if args.dry_run: print(f"workspace_info_path\t{config.workspace_info_path}") print(f"workspace_info_url\t{config.workspace_info_url}") print(f"status\t{workspace_info_status(config)}") print(f"command\t{'pull' if (info_root / '.git').is_dir() else 'clone'}") return sync_workspace_info(config, pull_existing=True) print(f"workspace_info_path\t{config.workspace_info_path}") print(f"status\tready") return if args.workspace_command == "migrate": migrated = 0 already_ready = 0 candidates = sorted(path for path in config.series_root.glob("*/*") if path.is_dir()) for card_path in candidates: legacy_state = card_path / ".rv" stem_state = card_path / ".stem" if stem_state.exists() or stem_state.is_symlink(): already_ready += 1 continue if not legacy_state.is_dir(): continue print(f"state\t{stem_state} -> .rv") migrated += 1 if not args.dry_run: stem_state.symlink_to(".rv", target_is_directory=True) print(f"migrated\t{migrated}") print(f"already_ready\t{already_ready}") print(f"mode\t{'dry-run' if args.dry_run else 'applied'}") return if args.workspace_command == "doctor": requested_runtime = os.environ.get("STEM_RUNTIME", "").strip() runtime = requested_runtime or ("podman" if shutil.which("podman") else "") runtime_ok = runtime == "podman" and shutil.which("podman") is not None if requested_runtime and requested_runtime != "podman": runtime_detail = f"{requested_runtime} is not supported for the rootless student workflow" else: runtime_detail = runtime or "missing podman" checks: list[tuple[str, bool, str]] = [ ("workspace", config.workspace_root.is_dir(), str(config.workspace_root)), ("series", config.series_root.is_dir(), str(config.series_root)), ("env-tool", env_tool_command_path(config).is_file(), str(env_tool_command_path(config))), ("runtime", runtime_ok, runtime_detail), ] socket_probe = config.socket_root / ("t" * 12) / ("i" * 12) / ("c" * 12) / "n.sock" checks.append(("socket-path", len(os.fsencode(socket_probe)) <= 100, f"{len(os.fsencode(socket_probe))} bytes")) if runtime_ok: probe = subprocess.run( ["podman", "info", "--format", "{{.Host.Security.Rootless}}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) checks.append(("rootless", probe.returncode == 0 and probe.stdout.strip() == "true", probe.stdout.strip() or probe.stderr.strip())) else: checks.append(("rootless", False, "rootless Podman is required")) print("check\tstatus\tdetail") for name, ok, detail in checks: print(f"{name}\t{'ok' if ok else 'FAIL'}\t{detail}") if not all(ok for _, ok, _ in checks): raise SystemExit(1) return raise SystemExit(f"Unsupported workspace command: {args.workspace_command}") def print_config(config: WorkspaceConfig) -> None: print(f"config_path\t{config.config_path}") print(f"original_root\t{config.original_root}") print(f"original_series_root\t{config.original_series_root}") print(f"workspace_root\t{config.workspace_root}") print(f"workspace_info_path\t{config.workspace_info_path}") print(f"workspace_info_url\t{config.workspace_info_url}") print(f"workspace_info_status\t{workspace_info_status(config)}") print(f"series_root\t{config.series_root}") print(f"socket_root\t{config.socket_root}") print(f"token_path\t{config.token_path}") print(f"tools_root\t{config.tools_root}") print(f"env_tool_url\t{config.env_tool_url}") print(f"git_base_url\t{config.git.base_url}") print(f"git_source_org\t{config.git.source_org}") print(f"git_answer_org\t{config.git.answer_org}") print(f"git_source_remote\t{config.git.source_remote}") print(f"git_answer_remote\t{config.git.answer_remote}") print(f"git_origin_remote\t{config.git.origin_remote}") print(f"git_fallback_branch\t{config.git.fallback_branch}") print("tools_root_candidates") for path in config.tools_root_candidates: print(f" {path}") def add_repo_option(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--repo", help="Path inside a target git repo. Default: current working directory.", ) 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="Remote id inside tokens.json, for example 'r1'. Default: --remote.") def print_table(headers: list[str], rows: list[list[str]]) -> None: widths = [len(header) for header in headers] for row in rows: for index, value in enumerate(row): widths[index] = max(widths[index], len(value)) print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))) print(" ".join("-" * width for width in widths)) for row in rows: print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row))) def print_config_summary(config_path: Path) -> None: try: config = load_config(config_path) except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error: print("Config:") print_table( ["field", "value"], [ ["config", str(config_path)], ["status", f"unavailable: {error}"], ], ) return print("Config:") print_table( ["field", "value"], [ ["config", str(config.config_path)], ["workspace", str(config.workspace_root)], ["series", str(config.series_root)], ["tokens", str(config.token_path)], ], ) def print_main_overview(config_path: Path) -> None: print("STEMCTL - launcher kart pracy STEM") print() print("Usage:") print(" ./stemctl [options]") print(" ./stemctl tokens [options]") print(" ./stemctl --help") print() print("Commands:") print_table( ["command", "arguments", "purpose"], [ ["show-config", "", "show resolved paths and Git defaults"], ["workspace", "show|sync|migrate|doctor", "manage and diagnose workspace"], ["series", "list|use|cards|fetch|tasks", "work with series, cards and tasks"], ["card", "use [series] card", "set the default card, optionally with the default series"], ["tasks", "list|show|switch", "shortcut for tasks in the default card"], ["env", "list|sync|ensure|build", "build three container profiles from Dockerfile"], ["build/test/run/debug", "PROFILE [selector]", "execute a card action"], ["deploy", "rp2350 [selector] --target TARGET", "flash a physical RP2350"], ["start/status/attach/stop/rm", "PROFILE [selector]", "manage a persistent rootless container"], ["probe", "list", "discover RP2350 probes and BOOTSEL targets"], ["mcp", "tmux|nvim INSTANCE", "serve one verified container session over stdio"], ["list-series", "", "compat alias for series list"], ["list-cards", "[series]", "compat alias for series cards list"], ["tmux-container", "[series] [card]", "compat: run canonical rootless stem shell in pane 0"], ["submission", "[series] [card] --class K [--task T]", "prepare answer repo and student branch"], ["tokens", "list|compare|cmp|sync|update|remove|rm|add|read|stats|write", "manage tokens.json and Git remote credentials"], ], ) print() print("Typical flow:") print_table( ["step", "command", "result"], [ ["1", "./stemctl workspace sync", "clone or update workspace-info"], ["2", "./stemctl series cards fetch inf bss", "fetch the card on the host"], ["3", "./stemctl env ensure native-amd64", "build or reuse the local native profile"], ["4", "./stemctl test native-amd64 1", "run native golden/sanitizer tests"], ["5", "./stemctl test hazard3-sim 1", "run the same vectors on Hazard3"], ["6", "./stemctl debug hazard3-sim 1", "open common tmux/nvim/GDB UI"], ["7", "./stemctl deploy rp2350 1 --target rp2350-rv --device /dev/bus/usb/001/006", "flash during a practical class"], ], ) print() print_config_summary(config_path) print() print("Docs:") print(" doc/rvctl.md") print(" doc/tokens.md") print(" doc/tokens.schema.json") print(" doc/containers.md") print(" doc/workspace.md") def print_tokens_overview() -> None: print("RVCTL tokens - token store and Git remote credentials") print() print("Usage:") print(" ./rvctl tokens [options]") print(" ./rvctl tokens --help") print() print("Commands:") print_table( ["command", "common options", "direction", "purpose"], [ ["list", "store|remote|both", "read-only", "list one source without comparing it"], ["compare/cmp", "[--repo PATH]", "read-only", "compare remote/store with marker and auth masks"], ["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"], ["remove/rm", "store|remote|both REMOTE_ID", "selected", "remove token store record or Git remote"], ["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"], ["update", "REMOTE_ID", "API -> tokens.json", "refresh valid/scope/org/repo metadata"], ], ) print() print("Examples:") print_table( ["case", "command"], [ ["list token store", "./rvctl tokens list store"], ["list launcher remotes", "./rvctl tokens list remote"], ["compare launcher state", "./rvctl tokens cmp"], ["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"], ["remove r1 from store", "./rvctl tokens rm store r1"], ["remove Git remote r1", "./rvctl tokens rm remote r1"], ["compare selected card", "./rvctl tokens cmp --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"], ["stats selected card", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"], ["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL"], ], ) def extract_config_path(argv: list[str]) -> tuple[Path, list[str]]: config_path = Path(__file__).with_name("workspace.json") remaining: list[str] = [] index = 0 while index < len(argv): arg = argv[index] if arg == "--config": if index + 1 >= len(argv): remaining.append(arg) index += 1 continue config_path = Path(argv[index + 1]) index += 2 continue if arg.startswith("--config="): config_path = Path(arg.split("=", 1)[1]) index += 1 continue remaining.append(arg) index += 1 return config_path, remaining def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog=os.environ.get("STEM_CLI_NAME", Path(sys.argv[0]).name), description="STEM card launcher for native AMD64, Hazard3 simulation and RP2350.", ) parser.add_argument( "--config", default=Path(__file__).with_name("workspace.json"), type=Path, help="Path to workspace.json", ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("show-config", help="Print resolved workspace paths.") subparsers.add_parser("list-series", help="List series directories from series_root.") cards_parser = subparsers.add_parser("list-cards", help="List cards in a series.") cards_parser.add_argument("series", nargs="?", help="Series id, for example 'inf'.") workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.") workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True) workspace_subparsers.add_parser("show", help="Show workspace-info configuration.") workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.") workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") workspace_migrate_parser = workspace_subparsers.add_parser( "migrate", help="Expose legacy .rv state as .stem without deleting data." ) workspace_migrate_parser.add_argument("--dry-run", action="store_true", help="Only list state directories.") workspace_subparsers.add_parser("doctor", help="Check workspace, rootless runtime and MCP socket budget.") env_parser = subparsers.add_parser("env", help="Manage the three canonical container environments.") env_subparsers = env_parser.add_subparsers(dest="env_command", required=True) env_subparsers.add_parser("list", help="List supported environment profiles.") env_sync_parser = env_subparsers.add_parser("sync", help="Clone or update rv32i-hazard3-student-env into tools/.") env_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") env_sources_parser = env_subparsers.add_parser( "sources", help="Prepare or verify pinned Hazard3 sources in one host card workspace." ) env_sources_parser.add_argument("series", nargs="?", help="Series id; defaults to workspace.json.") env_sources_parser.add_argument("card", nargs="?", help="Card id; defaults to workspace.json.") env_sources_parser.add_argument("--check", action="store_true", help="Verify without changing the workspace.") env_sources_parser.add_argument("--dry-run", action="store_true", help="Print the preparation command only.") env_build_parser = env_subparsers.add_parser("build", help="Build one container profile.") env_build_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") env_build_parser.add_argument("--dry-run", action="store_true", help="Print command without building.") env_ensure_parser = env_subparsers.add_parser("ensure", help="Reuse or locally build a profile from Dockerfile.") env_ensure_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") env_ensure_parser.add_argument("--dry-run", action="store_true", help="Print command without building.") for action, help_text in ( ("build", "Build the selected task inside a profile."), ("test", "Run the selected task tests inside a profile."), ("run", "Run the selected task without implicit deployment."), ("debug", "Open the selected task in the common tmux/nvim debugger UI."), ("deploy", "Deploy an RP2350 artifact using the explicitly selected probe."), ("shell", "Open a shell in the persistent profile container."), ("start", "Create or start the persistent profile container."), ("status", "Show status of the selected profile container."), ("attach", "Attach to the selected profile container UI."), ("stop", "Stop the selected profile container."), ("rm", "Remove the selected profile container; sources remain on the host."), ): action_parser = subparsers.add_parser(action, help=help_text) action_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.") action_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].") action_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.") action_parser.add_argument("--target", help="Target override, for example rp2350-rv or rp2350-arm.") action_parser.add_argument("--instance", help="Stable logical container instance name.") action_parser.add_argument("--pane", help="Send command to a host tmux pane (for example 0 or %%3).") action_parser.add_argument("--dry-run", action="store_true", help="Print command without changing state.") if action in {"debug", "shell", "attach"}: action_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.") if action in {"debug", "deploy"}: action_parser.add_argument( "--device", help="Exact host device path reported by 'stemctl probe list'.", ) if action == "deploy": action_parser.add_argument( "--backend", choices=["probe", "bootsel"], default="probe", help="Deployment transport (default: probe).", ) if action == "run": action_parser.add_argument( "--allow-existing-firmware", action="store_true", help="For RP2350 only: run firmware whose deployment cannot be verified.", ) probe_parser = subparsers.add_parser("probe", help="Discover RP2350 debug probes and BOOTSEL devices.") probe_subparsers = probe_parser.add_subparsers(dest="probe_command", required=True) probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.") probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") mcp_parser = subparsers.add_parser("mcp", help="Select an MCP container or run its bundled MCP server.") mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True) for kind in ("tmux", "nvim"): kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.") kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).") mcp_select_parser = mcp_subparsers.add_parser( "select", help="Atomically point dynamic tmux and Neovim MCP sockets at one container." ) mcp_select_parser.add_argument("selector", help="Logical instance, container name, or at least 12 ID characters.") mcp_subparsers.add_parser("status", help="Show and validate the dynamically selected MCP container.") mcp_subparsers.add_parser("list", help="List registered container instances and socket readiness.") tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.") tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True) tasks_short_list_parser = tasks_subparsers.add_parser("list", help="List tasks from the default or selected card.") tasks_short_list_parser.add_argument("selector", nargs="*", help="Optional card selector: [card] or [series card].") tasks_short_show_parser = tasks_subparsers.add_parser("show", help="Show one task from the default or selected card.") tasks_short_show_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.") tasks_short_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.") tasks_short_switch_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.") tasks_short_switch_parser.add_argument("--branch", help="Override the branch name.") tasks_short_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.") card_parser = subparsers.add_parser("card", help="Manage the default card selector.") card_subparsers = card_parser.add_subparsers(dest="card_command", required=True) card_use_parser = card_subparsers.add_parser("use", help="Set the default card, optionally with the default series.") card_use_parser.add_argument("selector", nargs="+", help="Use '' or ' ', for example 'bss' or 'inf bss'.") tmux_parser = subparsers.add_parser( "tmux-container", help="Compatibility wrapper: create host tmux with the canonical rootless stem shell in pane 0.", ) tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.") tmux_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.") tmux_parser.add_argument("--profile", choices=ENV_PROFILE_CHOICES, default="hazard3-sim", help="Container profile.") tmux_parser.add_argument("--session", help="tmux session name.") tmux_parser.add_argument("--window", help="tmux window name.") tmux_parser.add_argument("--instance", help="Stable STEM_INSTANCE passed to the canonical runtime.") tmux_parser.add_argument("--attach", action="store_true", help="Attach to the session after creation.") tmux_parser.add_argument("--dry-run", action="store_true", help="Print commands instead of running tmux.") submission_parser = subparsers.add_parser( "submission", help="Print or apply source/answer git remotes for a selected card.", ) submission_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.") submission_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.") submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id, for example 'c2025-1a-inf'.") submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.") submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.") submission_parser.add_argument( "--branch", help="Override the submission branch name. Defaults to Ta.", ) submission_parser.add_argument( "--date", default=dt.date.today().isoformat(), help="Lesson date reported in the submission plan. Default: today in YYYY-MM-DD.", ) submission_parser.add_argument( "--source-url", help="Override the source repo URL. By default launcher reads r1 or origin from the card repo.", ) submission_parser.add_argument( "--apply", action="store_true", help="Write remotes into the selected card repo instead of only printing the plan.", ) series_parser = subparsers.add_parser("series", help="Work with series, cards and tasks.") series_subparsers = series_parser.add_subparsers(dest="series_command", required=True) series_subparsers.add_parser("list", help="List available series.") series_use_parser = series_subparsers.add_parser("use", help="Set the default series.") series_use_parser.add_argument("series", help="Series id, for example 'inf'.") series_show_parser = series_subparsers.add_parser("show", help="Show one series.") series_show_parser.add_argument("series", help="Series id, for example 'inf'.") series_fetch_parser = series_subparsers.add_parser("fetch", help="Fetch all cards in a series.") series_fetch_parser.add_argument("series", help="Series id, for example 'inf'.") series_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.") cards_group_parser = series_subparsers.add_parser("cards", help="Work with cards in a series.") cards_subparsers = cards_group_parser.add_subparsers(dest="cards_command", required=True) cards_list_parser = cards_subparsers.add_parser("list", help="List cards in a series.") cards_list_parser.add_argument("series", help="Series id, for example 'inf'.") cards_show_parser = cards_subparsers.add_parser("show", help="Show one card.") cards_show_parser.add_argument("series", help="Series id, for example 'inf'.") cards_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") cards_fetch_parser = cards_subparsers.add_parser("fetch", help="Fetch one card into workspace.") cards_fetch_parser.add_argument("series", help="Series id, for example 'inf'.") cards_fetch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") cards_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.") cards_submission_parser = cards_subparsers.add_parser("submission", help="Print/apply answer remotes for a card.") cards_submission_parser.add_argument("series", help="Series id, for example 'inf'.") cards_submission_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") cards_submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id.") cards_submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.") cards_submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.") cards_submission_parser.add_argument("--branch", help="Override the submission branch name.") cards_submission_parser.add_argument("--date", default=dt.date.today().isoformat(), help="Lesson date in YYYY-MM-DD.") cards_submission_parser.add_argument("--source-url", help="Override source repo URL.") cards_submission_parser.add_argument("--apply", action="store_true", help="Write remotes into the selected card repo.") tasks_group_parser = cards_subparsers.add_parser("tasks", help="Work with tasks in a card.") tasks_subparsers = tasks_group_parser.add_subparsers(dest="tasks_command", required=True) tasks_list_parser = tasks_subparsers.add_parser("list", help="List tasks in a card.") tasks_list_parser.add_argument("series", help="Series id, for example 'inf'.") tasks_list_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") tasks_show_parser = tasks_subparsers.add_parser("show", help="Show one task.") tasks_show_parser.add_argument("series", help="Series id, for example 'inf'.") tasks_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") tasks_show_parser.add_argument("task", help="Task name or unique fragment.") tasks_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.") tasks_switch_parser.add_argument("series", help="Series id, for example 'inf'.") tasks_switch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.") tasks_switch_parser.add_argument("task", help="Task name or unique fragment, for example 'task4' or '4'.") tasks_switch_parser.add_argument("--branch", help="Override the branch name.") tasks_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.") tokens_parser = subparsers.add_parser("tokens", help="Manage local token store and repo remotes.") tokens_subparsers = tokens_parser.add_subparsers(dest="tokens_command", required=True) tokens_compare_parser = tokens_subparsers.add_parser( "compare", aliases=["cmp"], help="Compare repo remotes with tokens.json and print paired token rows.", ) tokens_compare_parser.add_argument( "--repo", help="Path inside a target git repo. Default: repo containing rvctl.", ) tokens_list_parser = tokens_subparsers.add_parser( "list", help="List token store records, Git remotes, or both without comparing them.", ) tokens_list_parser.add_argument("target", choices=["store", "remote", "both"], help="Source to list.") tokens_list_parser.add_argument( "--repo", help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.", ) tokens_list_parser.add_argument("--server", help="Filter output to one server endpoint.") tokens_read_parser = tokens_subparsers.add_parser( "read", help="Read tokens.json and print stored servers, token ids and remotes.", ) tokens_read_parser.add_argument("--server", help="Filter output to one server endpoint.") tokens_read_parser.add_argument("--show-secrets", action="store_true", help="Print full token values.") tokens_add_parser = tokens_subparsers.add_parser( "add", help="Add an empty token skeleton to tokens.json.", ) 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="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.") tokens_stats_parser = tokens_subparsers.add_parser( "stats", help="Print per-server token statistics from tokens.json.", ) tokens_stats_parser.add_argument( "--repo", help="Path inside a target git repo. Default: repo containing rvctl.", ) 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'.") tokens_sync_parser.add_argument( "--repo", help="Path inside a target git repo. Default: repo containing rvctl.", ) tokens_sync_parser.add_argument("--url", help="Override target remote URL when writing store -> remote.") 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_remove_parser = tokens_subparsers.add_parser( "remove", aliases=["rm"], help="Remove a token store record, a Git remote, or both.", ) tokens_remove_parser.add_argument("target", choices=["store", "remote", "both"], help="Where to remove REMOTE_ID from.") tokens_remove_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.") tokens_remove_parser.add_argument( "--repo", help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.", ) tokens_remove_parser.add_argument("--server", help="Server endpoint used to disambiguate store records.") tokens_remove_parser.add_argument("--dry-run", action="store_true", help="Print the planned removal without writing it.") tokens_write_parser = tokens_subparsers.add_parser( "write", help="Write credentials from tokens.json into a repo remote URL.", ) add_repo_option(tokens_write_parser) tokens_write_parser.add_argument("--remote", help="Remote name to update or create.") tokens_write_parser.add_argument("--url", help="Override target remote URL when creating or updating the remote.") add_store_selection_options(tokens_write_parser) tokens_write_parser.add_argument("--replace", action="store_true", help="Replace different credentials in the remote URL.") tokens_write_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.") tokens_update_parser = tokens_subparsers.add_parser( "update", 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", choices=["remotes", "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.") tokens_update_parser.add_argument("--url", help="Override target remote URL when --from store.") add_store_selection_options(tokens_update_parser) tokens_update_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing to remotes.") tokens_update_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.") return parser def main() -> int: config_path, command_args = extract_config_path(sys.argv[1:]) if not command_args or command_args == ["help"]: print_main_overview(config_path) return 0 if command_args in (["tokens"], ["help", "tokens"], ["tokens", "help"]): print_tokens_overview() return 0 parser = build_parser() args = parser.parse_args() config = load_config(args.config) profile_actions = {"build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm"} if args.command in {"list-series", "list-cards", "series", "card", "tasks", "tmux-container", "submission"} | profile_actions: ensure_workspace_info(config) if args.command == "show-config": print_config(config) return 0 if args.command == "list-series": print_series(config) return 0 if args.command == "list-cards": print_cards(config, args.series) return 0 if args.command == "workspace": run_workspace(config, args) return 0 if args.command == "env": run_env(config, args) return 0 if args.command == "probe": run_probe(config, args) return 0 if args.command == "mcp": run_mcp(config, args) return 0 if args.command in profile_actions: run_profile_action(config, args, args.command) return 0 if args.command == "tasks": run_tasks(config, args) return 0 if args.command == "card": run_card(config, args) return 0 if args.command == "tmux-container": run_tmux_container(config, args) return 0 if args.command == "submission": print_submission_plan(config, args) return 0 if args.command == "series": run_series(config, args) return 0 if args.command == "tokens": if args.tokens_command in {"compare", "cmp"}: run_tokens_compare(config, args) return 0 if args.tokens_command == "list": run_tokens_list(config, args) return 0 if args.tokens_command == "add": run_tokens_add(config, args) return 0 if args.tokens_command == "read": run_tokens_read(config, args) return 0 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 in {"remove", "rm"}: run_tokens_remove(config, args) return 0 if args.tokens_command == "write": run_tokens_write(config, args) return 0 if args.tokens_command == "update": run_tokens_update(config, args) return 0 parser.error(f"Unsupported tokens command: {args.tokens_command}") return 2 parser.error(f"Unsupported command: {args.command}") return 2 if __name__ == "__main__": sys.exit(main())