Created
January 24, 2026 09:13
-
-
Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "min_major": 5, | |
| "dotnet_dir": "~/.dotnet", | |
| "tools": [ | |
| "dotnet-ef", | |
| "csharpier", | |
| "dotnet-outdated-tool" | |
| ], | |
| "workloads": { | |
| "update": true, | |
| "from_previous_sdk": true | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import urllib.request | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional, Set, Tuple | |
| # ------------------------- Defaults (overridable by config.json) -------------- | |
| MIN_MAJOR_DEFAULT = 5 | |
| DOTNET_DIR_DEFAULT = Path.home() / ".dotnet" | |
| INSTALL_SCRIPT = Path.home() / "dotnet-install.sh" | |
| RELEASES_URL = "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json" | |
| DOTNET_INSTALL_SH_URL = "https://dot.net/v1/dotnet-install.sh" | |
| SCRIPT_DIR = Path(__file__).resolve().parent | |
| CONFIG_FILE = SCRIPT_DIR / "config.json" | |
| BLOCK_BEGIN = "# BEGIN .NET SDK setup" | |
| BLOCK_END = "# END .NET SDK setup" | |
| BLOCK_CONTENT = """# BEGIN .NET SDK setup | |
| export DOTNET_ROOT="$HOME/.dotnet" | |
| case ":$PATH:" in | |
| *":$DOTNET_ROOT:"*) ;; | |
| *) export PATH="$DOTNET_ROOT:$PATH" ;; | |
| esac | |
| case ":$PATH:" in | |
| *":$HOME/.dotnet/tools:"*) ;; | |
| *) export PATH="$HOME/.dotnet/tools:$PATH" ;; | |
| esac | |
| export DOTNET_CLI_TELEMETRY_OPTOUT="true" | |
| export DOTNET_NOLOGO="true" | |
| export DOTNET_SKIP_FIRST_TIME_EXPERIENCE="true" | |
| # END .NET SDK setup | |
| """.rstrip() + "\n" | |
| TOOL_SPEC_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*(?:@[A-Za-z0-9][A-Za-z0-9._-]*)?$") | |
| NON_POSIX_SHELLS = {"fish", "nu", "nushell", "xonsh", "elvish", "powershell", "pwsh"} | |
| # ------------------------- Runtime config (loaded from config.json) ----------- | |
| class Config: | |
| def __init__(self) -> None: | |
| self.min_major: int = MIN_MAJOR_DEFAULT | |
| self.dotnet_dir: Path = DOTNET_DIR_DEFAULT | |
| self.channels: Optional[List[str]] = None # None => auto-detect | |
| self.tools: List[str] = [] | |
| self.workload_update: bool = True | |
| self.workload_from_previous_sdk: bool = True | |
| @property | |
| def dotnet_path(self) -> Path: | |
| return self.dotnet_dir / "dotnet" | |
| def dotnet_cmd(self) -> str: | |
| if self.dotnet_path.exists() and os.access(self.dotnet_path, os.X_OK): | |
| return str(self.dotnet_path) | |
| return "dotnet" | |
| CFG = Config() | |
| RC_CHANGED = False | |
| # ------------------------- basic utils --------------------------------------- | |
| def die(msg: str, code: int = 1) -> None: | |
| print(f"ERROR: {msg}", file=sys.stderr) | |
| sys.exit(code) | |
| def info(msg: str) -> None: | |
| print(msg) | |
| def is_tty() -> bool: | |
| return sys.stdin.isatty() and sys.stdout.isatty() | |
| def run( | |
| cmd: List[str], | |
| *, | |
| check: bool = True, | |
| capture: bool = False, | |
| cwd: Optional[Path] = None, | |
| ) -> subprocess.CompletedProcess: | |
| if capture: | |
| return subprocess.run( | |
| cmd, | |
| text=True, | |
| cwd=str(cwd) if cwd else None, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=False, | |
| ) | |
| return subprocess.run(cmd, text=True, cwd=str(cwd) if cwd else None, check=check) | |
| def _as_bool(v: Any, default: bool) -> bool: | |
| return v if isinstance(v, bool) else default | |
| def _as_int(v: Any, default: int) -> int: | |
| if isinstance(v, int): | |
| return v | |
| if isinstance(v, str) and v.strip().isdigit(): | |
| return int(v.strip()) | |
| return default | |
| def _as_str_list(v: Any) -> Optional[List[str]]: | |
| if v is None: | |
| return None | |
| if not isinstance(v, list): | |
| return None | |
| out: List[str] = [] | |
| for item in v: | |
| if isinstance(item, str) and item.strip(): | |
| out.append(item.strip()) | |
| return out | |
| def _expand_path(p: str) -> Path: | |
| return Path(os.path.expanduser(p)).resolve() | |
| def is_linux() -> bool: | |
| return sys.platform.startswith("linux") | |
| # ------------------------- config.json --------------------------------------- | |
| def load_config() -> None: | |
| if not CONFIG_FILE.exists(): | |
| return | |
| try: | |
| cfg = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) | |
| except Exception as e: | |
| die(f"Failed to parse {CONFIG_FILE}: {e}") | |
| if not isinstance(cfg, dict): | |
| die(f"{CONFIG_FILE} must contain a JSON object at the root") | |
| CFG.min_major = _as_int(cfg.get("min_major"), CFG.min_major) | |
| dotnet_dir = cfg.get("dotnet_dir") | |
| if isinstance(dotnet_dir, str) and dotnet_dir.strip(): | |
| CFG.dotnet_dir = _expand_path(dotnet_dir.strip()) | |
| channels = _as_str_list(cfg.get("channels")) | |
| if channels is not None: | |
| CFG.channels = sorted(set(channels), key=natural_channel_sort_key) | |
| tools = _as_str_list(cfg.get("tools")) | |
| if tools is not None: | |
| cleaned: List[str] = [] | |
| for t in tools: | |
| if TOOL_SPEC_RE.match(t): | |
| cleaned.append(t) | |
| else: | |
| info(f"WARN: Skipping invalid tool spec in config.json: {t}") | |
| CFG.tools = cleaned | |
| workloads = cfg.get("workloads") | |
| if isinstance(workloads, dict): | |
| CFG.workload_update = _as_bool(workloads.get("update"), CFG.workload_update) | |
| CFG.workload_from_previous_sdk = _as_bool( | |
| workloads.get("from_previous_sdk"), CFG.workload_from_previous_sdk | |
| ) | |
| # ------------------------- Package manager detection + deps (NEW) ------------- | |
| def detect_package_manager() -> Optional[str]: | |
| """ | |
| Returns: "apt", "dnf", "pacman" or None. | |
| """ | |
| if shutil.which("apt-get") and shutil.which("dpkg"): | |
| return "apt" | |
| if shutil.which("dnf") and shutil.which("rpm"): | |
| return "dnf" | |
| if shutil.which("pacman"): | |
| return "pacman" | |
| return None | |
| def pkg_is_installed(pm: str, pkg: str) -> bool: | |
| if pm == "apt": | |
| r = run(["dpkg", "-s", pkg], check=False, capture=True) | |
| return r.returncode == 0 | |
| if pm == "dnf": | |
| r = run(["rpm", "-q", pkg], check=False, capture=True) | |
| return r.returncode == 0 | |
| if pm == "pacman": | |
| r = run(["pacman", "-Qi", pkg], check=False, capture=True) | |
| return r.returncode == 0 | |
| return False | |
| def install_packages(pm: str, pkgs: List[str]) -> None: | |
| if not pkgs: | |
| return | |
| if pm == "apt": | |
| info("Installing missing dependencies via apt...") | |
| run(["sudo", "apt-get", "update"], check=True) | |
| run(["sudo", "apt-get", "install", "-y"] + pkgs, check=True) | |
| return | |
| if pm == "dnf": | |
| info("Installing missing dependencies via dnf...") | |
| run(["sudo", "dnf", "install", "-y"] + pkgs, check=True) | |
| return | |
| if pm == "pacman": | |
| info("Installing missing dependencies via pacman...") | |
| run(["sudo", "pacman", "-Sy", "--needed"] + pkgs, check=True) | |
| return | |
| die(f"Unsupported package manager: {pm}") | |
| def linux_dotnet_deps(pm: str) -> List[str]: | |
| """ | |
| Best-effort dependency set for default fresh installs. | |
| We keep this conservative and cross-version-friendly: | |
| - ca-certificates: HTTPS/TLS sanity | |
| - zlib: required by many native parts | |
| - ICU: globalization support | |
| - OpenSSL runtime libs | |
| """ | |
| if pm == "apt": | |
| # Debian/Ubuntu names vary by release; these are usually present/valid. | |
| # Note: libssl* exact package differs (libssl3, libssl1.1). We pick "libssl-dev" as a robust resolver. | |
| # If you prefer runtime-only, swap to "libssl3" and handle older releases separately. | |
| return ["ca-certificates", "zlib1g", "libicu-dev", "libssl-dev"] | |
| if pm == "dnf": | |
| return ["ca-certificates", "zlib", "libicu", "openssl-libs"] | |
| if pm == "pacman": | |
| return ["ca-certificates", "zlib", "icu", "openssl"] | |
| return [] | |
| def ensure_linux_dependencies() -> None: | |
| """ | |
| Installs missing dependencies only on Linux, idempotently. | |
| """ | |
| if not is_linux(): | |
| return | |
| pm = detect_package_manager() | |
| if pm is None: | |
| info("WARN: Could not detect a supported package manager (apt/dnf/pacman). Skipping dependency install.") | |
| return | |
| wanted = linux_dotnet_deps(pm) | |
| missing = [p for p in wanted if not pkg_is_installed(pm, p)] | |
| if not missing: | |
| info("Linux dependencies: already satisfied.") | |
| return | |
| info("Linux dependencies missing:") | |
| for p in missing: | |
| info(f" - {p}") | |
| install_packages(pm, missing) | |
| # ------------------------- dotnet install script ------------------------------ | |
| def ensure_install_script() -> None: | |
| if INSTALL_SCRIPT.exists() and INSTALL_SCRIPT.stat().st_size > 0: | |
| return | |
| info("Downloading dotnet-install.sh...") | |
| try: | |
| with urllib.request.urlopen(DOTNET_INSTALL_SH_URL) as r: | |
| data = r.read() | |
| INSTALL_SCRIPT.write_bytes(data) | |
| INSTALL_SCRIPT.chmod(0o755) | |
| except Exception as e: | |
| die(f"Failed downloading dotnet-install.sh: {e}") | |
| # ------------------------- releases / channels -------------------------------- | |
| def fetch_releases_index() -> Dict[str, Any]: | |
| try: | |
| with urllib.request.urlopen(RELEASES_URL) as r: | |
| return json.load(r) | |
| except Exception as e: | |
| die(f"Failed fetching releases index: {e}") | |
| raise RuntimeError("unreachable") | |
| def parse_channel_major(channel: str) -> Optional[int]: | |
| m = re.match(r"^(\d+)\.", channel) | |
| return int(m.group(1)) if m else None | |
| def natural_channel_sort_key(ch: str) -> Tuple[int, int, str]: | |
| m = re.match(r"^(\d+)\.(\d+)(.*)$", ch) | |
| if not m: | |
| return (9999, 9999, ch) | |
| return (int(m.group(1)), int(m.group(2)), m.group(3) or "") | |
| def normalize_channel_arg(arg: str) -> str: | |
| a = arg.strip() | |
| if re.fullmatch(r"\d+", a): | |
| return f"{int(a)}.0" | |
| if re.fullmatch(r"\d+\.\d+", a): | |
| maj, minor = a.split(".", 1) | |
| return f"{int(maj)}.{int(minor)}" | |
| die(f"Invalid channel argument: {arg}. Use like: install 8 or install 8.0") | |
| raise RuntimeError("unreachable") | |
| def is_stable_channel(ch: str) -> bool: | |
| return re.fullmatch(r"\d+\.\d+", ch) is not None | |
| def get_channels_filtered(index: Dict[str, Any]) -> List[str]: | |
| chans: Set[str] = set() | |
| for entry in index.get("releases-index", []): | |
| ch = entry.get("channel-version") | |
| if not isinstance(ch, str): | |
| continue | |
| major = parse_channel_major(ch) | |
| if major is None or major < CFG.min_major: | |
| continue | |
| chans.add(ch) | |
| return sorted(chans, key=natural_channel_sort_key) | |
| def get_latest_release_per_channel(index: Dict[str, Any]) -> List[Tuple[str, str]]: | |
| pairs: List[Tuple[str, str]] = [] | |
| for entry in index.get("releases-index", []): | |
| ch = entry.get("channel-version") | |
| lr = entry.get("latest-release") | |
| if isinstance(ch, str) and isinstance(lr, str): | |
| major = parse_channel_major(ch) | |
| if major is not None and major >= CFG.min_major: | |
| pairs.append((ch, lr)) | |
| return sorted(pairs, key=lambda p: natural_channel_sort_key(p[0])) | |
| def latest_stable_channel(index: Dict[str, Any]) -> str: | |
| channels = [c for c in get_channels_filtered(index) if is_stable_channel(c)] | |
| if not channels: | |
| die("Could not determine latest stable channel from releases index.") | |
| return sorted(channels, key=natural_channel_sort_key)[-1] | |
| def installed_channels() -> List[str]: | |
| sdk_dir = CFG.dotnet_dir / "sdk" | |
| if not sdk_dir.exists(): | |
| return [] | |
| out: Set[str] = set() | |
| for d in sdk_dir.iterdir(): | |
| if not d.is_dir(): | |
| continue | |
| parts = d.name.split(".") | |
| if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit(): | |
| out.add(f"{parts[0]}.{parts[1]}") | |
| return sorted(out, key=natural_channel_sort_key) | |
| # ------------------------- dynamic shell / rc detection ----------------------- | |
| def detect_shell_binaries() -> List[str]: | |
| shells: Set[str] = set() | |
| env_shell = os.environ.get("SHELL", "") | |
| if env_shell: | |
| shells.add(Path(env_shell).name) | |
| etc_shells = Path("/etc/shells") | |
| if etc_shells.exists(): | |
| try: | |
| for line in etc_shells.read_text().splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| shells.add(Path(line).name) | |
| except Exception: | |
| pass | |
| for name in ("bash", "zsh", "ksh", "mksh", "dash", "sh", "busybox"): | |
| if shutil.which(name): | |
| shells.add(name) | |
| return sorted(shells) | |
| def is_posixish_shell(shell_name: str) -> bool: | |
| return shell_name.lower() not in NON_POSIX_SHELLS | |
| def canonical_rc_for_current_shell(shell_name: str) -> List[Path]: | |
| home = Path.home() | |
| name = shell_name.lower() | |
| if name.endswith("zsh"): | |
| return [home / ".zshrc"] | |
| if name.endswith("bash"): | |
| return [home / ".bashrc"] | |
| if name in ("ksh", "mksh"): | |
| return [home / f".{name}rc"] | |
| return [home / f".{shell_name}rc"] | |
| def existing_rc_candidates_for_shell(shell_name: str) -> List[Path]: | |
| home = Path.home() | |
| name = shell_name | |
| candidates = [ | |
| home / f".{name}rc", | |
| home / f".{name}_rc", | |
| home / f".{name}profile", | |
| home / f".{name}_profile", | |
| home / f".{name}env", | |
| home / f".{name}_env", | |
| home / f".{name}login", | |
| home / f".{name}_login", | |
| home / ".config" / name / "rc", | |
| home / ".config" / name / f"{name}rc", | |
| home / ".config" / name / f"config.{name}", | |
| ] | |
| return [p for p in candidates if p.exists()] | |
| def rc_files_to_patch() -> List[Path]: | |
| shells = detect_shell_binaries() | |
| files: List[Path] = [] | |
| for sh_name in shells: | |
| if not is_posixish_shell(sh_name): | |
| continue | |
| files.extend(existing_rc_candidates_for_shell(sh_name)) | |
| env_shell = os.environ.get("SHELL", "") | |
| if env_shell: | |
| sh = Path(env_shell).name | |
| if is_posixish_shell(sh): | |
| files.extend(canonical_rc_for_current_shell(sh)) | |
| seen: Set[Path] = set() | |
| out: List[Path] = [] | |
| for p in files: | |
| if p not in seen: | |
| out.append(p) | |
| seen.add(p) | |
| return out | |
| # ------------------------- rc block editing ---------------------------------- | |
| def ensure_rc_block(rc: Path) -> bool: | |
| global RC_CHANGED | |
| rc.parent.mkdir(parents=True, exist_ok=True) | |
| if not rc.exists(): | |
| rc.write_text("") | |
| text = rc.read_text() | |
| if BLOCK_BEGIN in text and BLOCK_END in text: | |
| start = text.index(BLOCK_BEGIN) | |
| end_idx = text.index(BLOCK_END, start) + len(BLOCK_END) | |
| current = text[start:end_idx].rstrip("\n") | |
| desired = BLOCK_CONTENT.rstrip("\n") | |
| if current == desired: | |
| return False | |
| rc.write_text(text[:start] + BLOCK_CONTENT + text[end_idx:]) | |
| RC_CHANGED = True | |
| return True | |
| new_text = text | |
| if new_text and not new_text.endswith("\n"): | |
| new_text += "\n" | |
| if new_text and not new_text.endswith("\n\n"): | |
| new_text += "\n" | |
| rc.write_text(new_text + BLOCK_CONTENT) | |
| RC_CHANGED = True | |
| return True | |
| def remove_rc_block(rc: Path) -> bool: | |
| global RC_CHANGED | |
| if not rc.exists(): | |
| return False | |
| text = rc.read_text() | |
| if BLOCK_BEGIN not in text or BLOCK_END not in text: | |
| return False | |
| start = text.index(BLOCK_BEGIN) | |
| end_idx = text.index(BLOCK_END, start) + len(BLOCK_END) | |
| new_text = text[:start] + text[end_idx:] | |
| new_text = re.sub(r"\n{4,}", "\n\n", new_text) | |
| rc.write_text(new_text) | |
| RC_CHANGED = True | |
| return True | |
| def patch_rc_files() -> None: | |
| for rc in rc_files_to_patch(): | |
| ensure_rc_block(rc) | |
| def unpatch_rc_files() -> None: | |
| for rc in rc_files_to_patch(): | |
| remove_rc_block(rc) | |
| # ------------------------- CLI checklist (curses) ----------------------------- | |
| def select_channels_curses(channels: List[str], prechecked: List[str]) -> List[str]: | |
| if not is_tty(): | |
| if prechecked: | |
| return prechecked[:] | |
| die("No TTY available for interactive selection.", 1) | |
| raise RuntimeError("unreachable") | |
| try: | |
| import curses # stdlib | |
| except Exception: | |
| return select_channels_fallback_text(channels, prechecked) | |
| pre = set(prechecked) | |
| checked = [c in pre for c in channels] | |
| cursor_visible = 0 | |
| filter_text = "" | |
| def visible_indices() -> List[int]: | |
| if not filter_text: | |
| return list(range(len(channels))) | |
| ft = filter_text.lower() | |
| return [i for i, ch in enumerate(channels) if ft in ch.lower()] | |
| def selected_count() -> int: | |
| return sum(1 for v in checked if v) | |
| def prompt_filter(stdscr) -> None: | |
| nonlocal filter_text, cursor_visible | |
| h, w = stdscr.getmaxyx() | |
| prompt = "Filter: " | |
| stdscr.move(h - 1, 0) | |
| stdscr.clrtoeol() | |
| stdscr.addnstr(h - 1, 0, prompt, max(0, w - 1)) | |
| stdscr.refresh() | |
| curses.echo() | |
| try: | |
| raw = stdscr.getstr( | |
| h - 1, | |
| min(len(prompt), max(0, w - 1)), | |
| max(0, w - len(prompt) - 1), | |
| ) | |
| try: | |
| s = raw.decode("utf-8", errors="ignore").strip() | |
| except Exception: | |
| s = str(raw).strip() | |
| filter_text = s | |
| finally: | |
| curses.noecho() | |
| cursor_visible = 0 | |
| help_line = "↑/↓ j/k Space Enter / a n i q/Esc" | |
| def ui(stdscr): | |
| nonlocal cursor_visible | |
| curses.curs_set(0) | |
| stdscr.keypad(True) | |
| while True: | |
| stdscr.erase() | |
| h, w = stdscr.getmaxyx() | |
| vis = visible_indices() | |
| stdscr.addnstr(0, 0, "Select .NET SDK channels", max(0, w - 1)) | |
| stdscr.hline(1, 0, "-", max(0, w - 1)) | |
| if not vis: | |
| msg = f'No matches for filter: "{filter_text}" (press / to change; empty clears)' | |
| stdscr.addnstr(3, 0, msg, max(0, w - 1)) | |
| stdscr.hline(h - 2, 0, "-", max(0, w - 1)) | |
| footer = f"Selected: {selected_count()}/{len(channels)} Filter: {filter_text or '—'} {help_line}" | |
| stdscr.addnstr(h - 1, 0, footer, max(0, w - 1)) | |
| stdscr.refresh() | |
| key = stdscr.getch() | |
| if key == ord("/"): | |
| prompt_filter(stdscr) | |
| elif key in (27, ord("q")): | |
| return [] | |
| elif key in (curses.KEY_ENTER, 10, 13): | |
| curses.flash() | |
| continue | |
| cursor_visible = max(0, min(cursor_visible, len(vis) - 1)) | |
| view_height = max(1, h - 4) | |
| view_top = max(0, min(cursor_visible - view_height + 1, len(vis) - view_height)) | |
| view_top = max(0, view_top) | |
| for row in range(view_height): | |
| vpos = view_top + row | |
| if vpos >= len(vis): | |
| break | |
| oi = vis[vpos] | |
| mark = "[x]" if checked[oi] else "[ ]" | |
| line = f"{mark} {channels[oi]}" | |
| y = 2 + row | |
| if vpos == cursor_visible: | |
| stdscr.attron(curses.A_REVERSE) | |
| stdscr.addnstr(y, 0, line, max(0, w - 1)) | |
| stdscr.attroff(curses.A_REVERSE) | |
| else: | |
| stdscr.addnstr(y, 0, line, max(0, w - 1)) | |
| stdscr.hline(h - 2, 0, "-", max(0, w - 1)) | |
| footer = f"Selected: {selected_count()}/{len(channels)} Filter: {filter_text or '—'} {help_line}" | |
| stdscr.addnstr(h - 1, 0, footer, max(0, w - 1)) | |
| stdscr.refresh() | |
| key = stdscr.getch() | |
| if key in (curses.KEY_UP, ord("k")): | |
| cursor_visible = max(0, cursor_visible - 1) | |
| elif key in (curses.KEY_DOWN, ord("j")): | |
| cursor_visible = min(len(vis) - 1, cursor_visible + 1) | |
| elif key == ord(" "): | |
| oi = vis[cursor_visible] | |
| checked[oi] = not checked[oi] | |
| elif key in (curses.KEY_ENTER, 10, 13): | |
| selected = [ch for ch, on in zip(channels, checked) if on] | |
| if not selected: | |
| curses.flash() | |
| continue | |
| return selected | |
| elif key in (27, ord("q")): | |
| return [] | |
| elif key == ord("/"): | |
| prompt_filter(stdscr) | |
| elif key == ord("a"): | |
| for oi in vis: | |
| checked[oi] = True | |
| elif key == ord("n"): | |
| for oi in vis: | |
| checked[oi] = False | |
| elif key == ord("i"): | |
| for oi in vis: | |
| checked[oi] = not checked[oi] | |
| try: | |
| selected = curses.wrapper(ui) # type: ignore | |
| except KeyboardInterrupt: | |
| die("Cancelled.", code=130) | |
| except Exception: | |
| return select_channels_fallback_text(channels, prechecked) | |
| if not selected: | |
| die("No channels selected (cancelled).", code=130) | |
| return selected | |
| def select_channels_fallback_text(channels: List[str], prechecked: List[str]) -> List[str]: | |
| pre = set(prechecked) | |
| info("Select .NET SDK channels to install/update:") | |
| info("") | |
| for i, ch in enumerate(channels, start=1): | |
| mark = "*" if ch in pre else " " | |
| info(f" [{i:>2}] {mark} {ch}") | |
| info("") | |
| info("Enter numbers separated by commas/spaces (e.g. 1 3 5) or channel versions (e.g. 8.0 10.0).") | |
| info("Press Enter to use installed channels." if prechecked else "Press Enter to cancel.") | |
| raw = input("> ").strip() | |
| if not raw: | |
| if prechecked: | |
| return list(prechecked) | |
| die("No channels selected.", code=130) | |
| tokens = re.split(r"[,\s]+", raw) | |
| by_index = {str(i): ch for i, ch in enumerate(channels, start=1)} | |
| by_value = set(channels) | |
| selected: List[str] = [] | |
| for t in tokens: | |
| if not t: | |
| continue | |
| if t in by_index: | |
| selected.append(by_index[t]) | |
| elif t in by_value: | |
| selected.append(t) | |
| else: | |
| die(f"Invalid selection token: {t}") | |
| order = {c: i for i, c in enumerate(channels)} | |
| selected = sorted(set(selected), key=lambda x: order.get(x, 10**9)) | |
| if not selected: | |
| die("No channels selected.", code=130) | |
| return selected | |
| # ------------------------- workloads / tools ---------------------------------- | |
| def update_workloads() -> None: | |
| if not CFG.workload_update: | |
| return | |
| info("Updating .NET workloads...") | |
| cmd = [CFG.dotnet_cmd(), "workload", "update"] | |
| if CFG.workload_from_previous_sdk: | |
| cmd.append("--from-previous-sdk") | |
| run(cmd, check=False) | |
| def update_global_tools() -> None: | |
| info("Updating global .NET tools...") | |
| p = run([CFG.dotnet_cmd(), "tool", "list", "-g"], capture=True) | |
| txt = (p.stdout or "").strip() | |
| if not txt: | |
| info("No global tools found (or dotnet tool list returned no output).") | |
| return | |
| ids: List[str] = [] | |
| for line in txt.splitlines(): | |
| line = line.strip() | |
| if not line or line.lower().startswith("package id") or set(line) == {"-"}: | |
| continue | |
| parts = line.split() | |
| if parts: | |
| ids.append(parts[0]) | |
| if not ids: | |
| info("No global tools found.") | |
| return | |
| dn = CFG.dotnet_cmd() | |
| for tool_id in ids: | |
| info(f"Updating tool: {tool_id}") | |
| run([dn, "tool", "update", "-g", tool_id], check=False) | |
| def ensure_tools_from_config() -> None: | |
| if not CFG.tools: | |
| return | |
| info("Ensuring global tools from config.json...") | |
| dn = CFG.dotnet_cmd() | |
| for spec in CFG.tools: | |
| if "@" in spec: | |
| tool_id, ver = spec.split("@", 1) | |
| info(f"\n=== Tool: {tool_id} (version {ver}) ===") | |
| r = run([dn, "tool", "update", "-g", tool_id, "--version", ver], check=False) | |
| if r.returncode != 0: | |
| run([dn, "tool", "install", "-g", tool_id, "--version", ver], check=False) | |
| else: | |
| tool_id = spec | |
| info(f"\n=== Tool: {tool_id} ===") | |
| r = run([dn, "tool", "update", "-g", tool_id], check=False) | |
| if r.returncode != 0: | |
| run([dn, "tool", "install", "-g", tool_id], check=False) | |
| def post_update_maintenance() -> None: | |
| update_workloads() | |
| update_global_tools() | |
| ensure_tools_from_config() | |
| # ------------------------- dotnet install/update/uninstall -------------------- | |
| def install_or_update_channels(channels: List[str]) -> None: | |
| # NEW: install Linux deps before touching dotnet-install.sh | |
| ensure_linux_dependencies() | |
| ensure_install_script() | |
| CFG.dotnet_dir.mkdir(parents=True, exist_ok=True) | |
| patch_rc_files() | |
| for ch in channels: | |
| info(f"Installing/updating .NET SDK channel: {ch}") | |
| run([str(INSTALL_SCRIPT), "--channel", ch, "--install-dir", str(CFG.dotnet_dir)], check=True) | |
| post_update_maintenance() | |
| def update_installed() -> None: | |
| # NEW: ensure deps before updating | |
| ensure_linux_dependencies() | |
| installed = installed_channels() | |
| if not installed: | |
| die(f"No installed SDK channels found in {CFG.dotnet_dir}/sdk") | |
| ensure_install_script() | |
| CFG.dotnet_dir.mkdir(parents=True, exist_ok=True) | |
| patch_rc_files() | |
| for ch in installed: | |
| info(f"Updating installed channel: {ch}") | |
| run([str(INSTALL_SCRIPT), "--channel", ch, "--install-dir", str(CFG.dotnet_dir)], check=True) | |
| post_update_maintenance() | |
| def uninstall_dotnet() -> None: | |
| info(f"Removing {CFG.dotnet_dir} and installer...") | |
| if CFG.dotnet_dir.exists(): | |
| shutil.rmtree(CFG.dotnet_dir, ignore_errors=True) | |
| if INSTALL_SCRIPT.exists(): | |
| try: | |
| INSTALL_SCRIPT.unlink() | |
| except Exception: | |
| pass | |
| unpatch_rc_files() | |
| info("Uninstall complete.") | |
| # ------------------------- commands ------------------------------------------- | |
| def cmd_list() -> None: | |
| index = fetch_releases_index() | |
| for ch, lr in get_latest_release_per_channel(index): | |
| info(f"{ch}: {lr}") | |
| def resolve_channels_for_interactive() -> List[str]: | |
| if CFG.channels: | |
| return CFG.channels | |
| index = fetch_releases_index() | |
| channels = get_channels_filtered(index) | |
| if not channels: | |
| die("Could not retrieve channels from releases index.") | |
| return channels | |
| def cmd_default() -> None: | |
| channels = resolve_channels_for_interactive() | |
| selected = select_channels_curses(channels, installed_channels()) | |
| install_or_update_channels(selected) | |
| info("") | |
| info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk") | |
| def cmd_install(args: List[str]) -> None: | |
| index = fetch_releases_index() | |
| available = set(get_channels_filtered(index)) | |
| if not args: | |
| latest = latest_stable_channel(index) | |
| info(f"Installing latest stable channel: {latest}") | |
| install_or_update_channels([latest]) | |
| info("") | |
| info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk") | |
| return | |
| wanted = [normalize_channel_arg(a) for a in args] | |
| missing = [w for w in wanted if w not in available] | |
| if missing: | |
| die(f"Unknown/unsupported channel(s): {', '.join(missing)}") | |
| wanted_sorted = sorted(set(wanted), key=natural_channel_sort_key) | |
| install_or_update_channels(wanted_sorted) | |
| info("") | |
| info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk") | |
| def usage() -> None: | |
| exe = Path(sys.argv[0]).name | |
| print(f"""Usage: | |
| {exe} # interactive selection | |
| {exe} list # show latest release per channel | |
| {exe} update # update installed channels | |
| {exe} uninstall # uninstall dotnet from ~/.dotnet | |
| {exe} install [CH...] # install/update; no args installs latest stable | |
| """) | |
| def main() -> None: | |
| load_config() | |
| argv = sys.argv[1:] | |
| if argv and argv[0] in ("-h", "--help", "help"): | |
| usage() | |
| return | |
| if not argv: | |
| cmd_default() | |
| print("") | |
| print("Done. Reload: source ~/.zshrc" if RC_CHANGED else "Done.") | |
| return | |
| cmd = argv[0] | |
| args = argv[1:] | |
| if cmd == "list": | |
| cmd_list() | |
| elif cmd == "update": | |
| update_installed() | |
| elif cmd == "uninstall": | |
| uninstall_dotnet() | |
| elif cmd == "install": | |
| cmd_install(args) | |
| else: | |
| usage() | |
| sys.exit(1) | |
| print("") | |
| print("Done. Reload: source ~/.zshrc" if RC_CHANGED else "Done.") | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except KeyboardInterrupt: | |
| die("Cancelled.", code=130) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment