Last active
March 20, 2026 07:47
-
-
Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.
Dotnet Helper Script
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 | |
| }, | |
| "unlink_brew": true, | |
| "cleanup_old_versions": true, | |
| "verbose": false | |
| } |
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 | |
| self.unlink_brew: bool = True # Auto-unlink Homebrew dotnet on macOS | |
| self.cleanup_old_versions: bool = True # Remove old minor versions after update | |
| self.verbose: bool = False # Show detailed output | |
| @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 | |
| FORCE = False | |
| VERBOSE = 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 verbose_info(msg: str) -> None: | |
| """Print info message only in verbose mode.""" | |
| if VERBOSE: | |
| 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 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 | |
| ) | |
| CFG.unlink_brew = _as_bool(cfg.get("unlink_brew"), CFG.unlink_brew) | |
| CFG.cleanup_old_versions = _as_bool( | |
| cfg.get("cleanup_old_versions"), CFG.cleanup_old_versions | |
| ) | |
| CFG.verbose = _as_bool(cfg.get("verbose"), CFG.verbose) | |
| # ------------------------- macOS: Homebrew dependencies ---------------------- | |
| DOTNET_DEPS_BREW: List[str] = [ | |
| "openssl@3", | |
| "icu4c", | |
| ] | |
| def macos_brew_pkg_is_installed(pkg: str) -> bool: | |
| brew_cmd = shutil.which("brew") | |
| if not brew_cmd: | |
| return False | |
| r = run([brew_cmd, "list", "--formula", pkg], check=False, capture=True) | |
| return r.returncode == 0 | |
| def macos_install_brew_packages(pkgs: List[str]) -> None: | |
| brew_cmd = shutil.which("brew") | |
| if not brew_cmd: | |
| info("WARN: Homebrew not found. Cannot install macOS dependencies.") | |
| return | |
| info("Installing missing dependencies via Homebrew...") | |
| for pkg in pkgs: | |
| r = run([brew_cmd, "install", pkg], check=False) | |
| if r.returncode != 0: | |
| verbose_info(f" Skipping {pkg}: could not be installed.") | |
| # ------------------------- Linux: package manager / dependencies ------------- | |
| def linux_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 linux_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 linux_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=False) | |
| for pkg in pkgs: | |
| r = run(["sudo", "apt-get", "install", "-y", pkg], check=False) | |
| if r.returncode != 0: | |
| verbose_info(f" Skipping {pkg}: could not be installed.") | |
| return | |
| if pm == "dnf": | |
| info("Installing missing dependencies via dnf...") | |
| for pkg in pkgs: | |
| r = run(["sudo", "dnf", "install", "-y", pkg], check=False) | |
| if r.returncode != 0: | |
| verbose_info(f" Skipping {pkg}: could not be installed.") | |
| return | |
| if pm == "pacman": | |
| info("Installing missing dependencies via pacman...") | |
| for pkg in pkgs: | |
| r = run(["sudo", "pacman", "-Sy", "--needed", pkg], check=False) | |
| if r.returncode != 0: | |
| verbose_info(f" Skipping {pkg}: could not be installed.") | |
| return | |
| die(f"Unsupported package manager: {pm}") | |
| # Dependencies that share the same package name across all supported Linux package managers. | |
| DOTNET_DEPS_GLOBAL: List[str] = [ | |
| "ca-certificates", | |
| ] | |
| # Per-package-manager dependencies (names differ per distro family). | |
| DOTNET_DEPS_APT: List[str] = [ | |
| "zlib1g", | |
| "libstdc++6", | |
| "libicu74", | |
| "libssl3", | |
| "libkrb5-3", | |
| ] | |
| DOTNET_DEPS_DNF: List[str] = [ | |
| "zlib", | |
| "libstdc++", | |
| "libicu", | |
| "openssl-libs", | |
| "krb5-libs", | |
| ] | |
| DOTNET_DEPS_PACMAN: List[str] = [ | |
| "zlib", | |
| "gcc-libs", | |
| "icu", | |
| "openssl", | |
| "krb5", | |
| ] | |
| def linux_dotnet_deps(pm: str) -> List[str]: | |
| """Return the full dependency list for the given package manager.""" | |
| if pm == "apt": | |
| return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_APT | |
| if pm == "dnf": | |
| return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_DNF | |
| if pm == "pacman": | |
| return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_PACMAN | |
| return list(DOTNET_DEPS_GLOBAL) | |
| def linux_ensure_dependencies() -> None: | |
| """ | |
| Installs missing dependencies on Linux, idempotently. | |
| """ | |
| if not is_linux(): | |
| return | |
| pm = linux_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 linux_pkg_is_installed(pm, p)] | |
| if not missing: | |
| verbose_info("Linux dependencies: already satisfied.") | |
| return | |
| info("Linux dependencies missing:") | |
| for p in missing: | |
| info(f" - {p}") | |
| linux_install_packages(pm, missing) | |
| def ensure_dependencies() -> None: | |
| """Abstract: install any OS-level dependencies required by .NET.""" | |
| linux_ensure_dependencies() | |
| # ------------------------- dotnet install script ------------------------------ | |
| def ensure_install_script() -> None: | |
| if INSTALL_SCRIPT.exists() and INSTALL_SCRIPT.stat().st_size > 0: | |
| return | |
| verbose_info("Downloading dotnet-install.sh...") | |
| try: | |
| with urllib.request.urlopen(DOTNET_INSTALL_SH_URL, timeout=60) 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, timeout=30) 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 _iter_channels(index: Dict[str, Any]): | |
| 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 not None and major >= CFG.min_major: | |
| yield ch, entry | |
| def get_channels_filtered(index: Dict[str, Any]) -> List[str]: | |
| chans = {ch for ch, _ in _iter_channels(index)} | |
| 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 ch, entry in _iter_channels(index): | |
| lr = entry.get("latest-release") | |
| if isinstance(lr, str): | |
| 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 channels[-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) | |
| # Consume the trailing newline that belongs to the block so it isn't | |
| # duplicated when the block is rewritten. | |
| if end_idx < len(text) and text[end_idx] == "\n": | |
| end_idx += 1 | |
| 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) | |
| # Consume the trailing newline that belongs to the block so the surrounding | |
| # content isn't left with a stray leading newline. | |
| if end_idx < len(text) and text[end_idx] == "\n": | |
| end_idx += 1 | |
| 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 get_installed_global_tools() -> List[str]: | |
| """Get list of currently installed global tools.""" | |
| if not CFG.dotnet_path.exists(): | |
| return [] | |
| p = run([CFG.dotnet_cmd(), "tool", "list", "-g"], capture=True, check=False) | |
| if p.returncode != 0: | |
| return [] | |
| txt = (p.stdout or "").strip() | |
| if not txt: | |
| 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]) | |
| return ids | |
| 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() | |
| # ------------------------- macOS: code signatures / quarantine --------------- | |
| def macos_fix_code_signatures() -> None: | |
| """ | |
| Fix code signature issues on macOS by clearing all extended attributes and re-signing. | |
| IMPORTANT: This should be called AFTER all dotnet operations (workload/tool updates) | |
| because those operations download/modify files and can re-introduce quarantine flags | |
| or break code signatures. This is why signature errors appear randomly - they occur | |
| when workloads or tools are updated, not during initial SDK installation. | |
| """ | |
| if sys.platform != "darwin": | |
| return | |
| if not CFG.dotnet_dir.exists(): | |
| return | |
| verbose_info("Fixing macOS code signatures and quarantine flags...") | |
| # Step 1: Clear ALL extended attributes recursively (more aggressive) | |
| if shutil.which("xattr"): | |
| verbose_info(" Clearing extended attributes...") | |
| # -c clears all attributes, -r is recursive | |
| run(["xattr", "-cr", str(CFG.dotnet_dir)], check=False) | |
| # Step 2: Re-sign critical binaries with ad-hoc signature | |
| if shutil.which("codesign"): | |
| verbose_info(" Re-signing binaries...") | |
| dotnet_exe = CFG.dotnet_dir / "dotnet" | |
| if dotnet_exe.exists(): | |
| # Remove existing signature first | |
| run(["codesign", "--remove-signature", str(dotnet_exe)], check=False) | |
| # Re-sign with ad-hoc signature (allows local execution) | |
| run( | |
| ["codesign", "--force", "--deep", "--sign", "-", str(dotnet_exe)], | |
| check=False, | |
| ) | |
| # Also handle host executable | |
| host_exe = CFG.dotnet_dir / "host" / "fxr" | |
| if host_exe.exists() and host_exe.is_dir(): | |
| for version_dir in host_exe.iterdir(): | |
| if version_dir.is_dir(): | |
| for exe in version_dir.glob("*"): | |
| if exe.is_file() and os.access(exe, os.X_OK): | |
| run( | |
| ["codesign", "--remove-signature", str(exe)], | |
| check=False, | |
| ) | |
| run( | |
| ["codesign", "--force", "--sign", "-", str(exe)], | |
| check=False, | |
| ) | |
| def apply_platform_fixes() -> None: | |
| """Abstract: apply any OS-level fixes needed after SDK or tool operations.""" | |
| macos_fix_code_signatures() | |
| def clear_symbol_cache() -> None: | |
| symbol_cache = CFG.dotnet_dir / "symbolcache" | |
| if symbol_cache.exists(): | |
| verbose_info("Clearing .NET symbol cache...") | |
| shutil.rmtree(symbol_cache, ignore_errors=True) | |
| def remove_channel_sdks(channel: str) -> None: | |
| """Remove installed SDK directories for a channel so dotnet-install.sh performs a clean reinstall.""" | |
| sdk_dir = CFG.dotnet_dir / "sdk" | |
| if not sdk_dir.exists(): | |
| return | |
| prefix = channel + "." | |
| for d in sdk_dir.iterdir(): | |
| if d.is_dir() and d.name.startswith(prefix): | |
| info(f" Removing SDK {d.name} for forced reinstall...") | |
| shutil.rmtree(d, ignore_errors=True) | |
| def uninstall_channels(channels: List[str]) -> None: | |
| """Uninstall specific SDK channels.""" | |
| if not channels: | |
| return | |
| sdk_dir = CFG.dotnet_dir / "sdk" | |
| if not sdk_dir.exists(): | |
| return | |
| for ch in channels: | |
| info(f"Uninstalling .NET SDK channel: {ch}") | |
| prefix = ch + "." | |
| removed_any = False | |
| for d in sdk_dir.iterdir(): | |
| if d.is_dir() and d.name.startswith(prefix): | |
| info(f" Removing SDK {d.name}...") | |
| shutil.rmtree(d, ignore_errors=True) | |
| removed_any = True | |
| if not removed_any: | |
| info(f" No installed SDKs found for channel {ch}.") | |
| def get_channel_versions(channel: str) -> List[str]: | |
| """Get all installed SDK versions for a specific channel, sorted by version.""" | |
| sdk_dir = CFG.dotnet_dir / "sdk" | |
| if not sdk_dir.exists(): | |
| return [] | |
| prefix = channel + "." | |
| versions = [] | |
| for d in sdk_dir.iterdir(): | |
| if d.is_dir() and d.name.startswith(prefix): | |
| versions.append(d.name) | |
| # Sort by version number | |
| def version_sort_key(v: str) -> Tuple[int, ...]: | |
| parts = v.split(".") | |
| return tuple(int(p) if p.isdigit() else 0 for p in parts) | |
| return sorted(versions, key=version_sort_key) | |
| def cleanup_old_channel_versions(channel: str) -> None: | |
| """Remove old minor versions of a channel, keeping only the latest.""" | |
| if not CFG.cleanup_old_versions: | |
| return | |
| versions = get_channel_versions(channel) | |
| if len(versions) <= 1: | |
| return # Nothing to clean up | |
| latest = versions[-1] # Last item is the newest | |
| old_versions = versions[:-1] # Everything except the latest | |
| sdk_dir = CFG.dotnet_dir / "sdk" | |
| for old_ver in old_versions: | |
| verbose_info(f" Cleaning up old version: {old_ver}") | |
| old_path = sdk_dir / old_ver | |
| if old_path.exists(): | |
| shutil.rmtree(old_path, ignore_errors=True) | |
| # ------------------------- dotnet install/update/uninstall -------------------- | |
| def install_or_update_channels(channels: List[str]) -> None: | |
| 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}") | |
| if FORCE: | |
| remove_channel_sdks(ch) | |
| cmd = [ | |
| str(INSTALL_SCRIPT), | |
| "--channel", | |
| ch, | |
| "--install-dir", | |
| str(CFG.dotnet_dir), | |
| ] | |
| run(cmd, check=True) | |
| # Cleanup old versions after successful install/update | |
| cleanup_old_channel_versions(ch) | |
| clear_symbol_cache() | |
| post_update_maintenance() | |
| # Fix signatures AFTER all updates (workloads/tools can break them) | |
| apply_platform_fixes() | |
| # Ensure Homebrew dotnet doesn't interfere (macOS only) | |
| cmd_unlink_brew() | |
| def update_installed() -> None: | |
| 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}") | |
| if FORCE: | |
| remove_channel_sdks(ch) | |
| cmd = [ | |
| str(INSTALL_SCRIPT), | |
| "--channel", | |
| ch, | |
| "--install-dir", | |
| str(CFG.dotnet_dir), | |
| ] | |
| run(cmd, check=True) | |
| # Cleanup old versions after successful update | |
| cleanup_old_channel_versions(ch) | |
| clear_symbol_cache() | |
| post_update_maintenance() | |
| # Fix signatures AFTER all updates (workloads/tools can break them) | |
| apply_platform_fixes() | |
| # Ensure Homebrew dotnet doesn't interfere (macOS only) | |
| cmd_unlink_brew() | |
| 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_deps() -> None: | |
| """Check for missing .NET dependencies and install them after prompting.""" | |
| if sys.platform == "darwin": | |
| _cmd_deps_macos() | |
| elif is_linux(): | |
| _cmd_deps_linux() | |
| else: | |
| info("deps command is only applicable on macOS and Linux.") | |
| def _cmd_deps_macos() -> None: | |
| brew_cmd = shutil.which("brew") | |
| if not brew_cmd: | |
| info("WARN: Homebrew not found. Cannot check macOS dependencies.") | |
| return | |
| missing = [p for p in DOTNET_DEPS_BREW if not macos_brew_pkg_is_installed(p)] | |
| info("Homebrew dependencies:") | |
| for p in DOTNET_DEPS_BREW: | |
| status = "✗ missing" if p in missing else "✓ installed" | |
| info(f" {status}: {p}") | |
| if not missing: | |
| info("\n✓ All macOS .NET dependencies are already installed.") | |
| return | |
| info(f"\nMissing ({len(missing)}):") | |
| for p in missing: | |
| info(f" - {p}") | |
| info("") | |
| try: | |
| answer = input("Install missing packages? [y/N] ").strip().lower() | |
| except (EOFError, KeyboardInterrupt): | |
| info("") | |
| info("Cancelled.") | |
| return | |
| if answer not in ("y", "yes"): | |
| info("Skipped.") | |
| return | |
| macos_install_brew_packages(missing) | |
| info("✓ Dependencies installed.") | |
| def _cmd_deps_linux() -> None: | |
| pm = linux_detect_package_manager() | |
| if pm is None: | |
| info( | |
| "WARN: Could not detect a supported package manager (apt/dnf/pacman). Cannot check dependencies." | |
| ) | |
| return | |
| wanted = linux_dotnet_deps(pm) | |
| missing = [p for p in wanted if not linux_pkg_is_installed(pm, p)] | |
| info(f"Global dependencies (shared across all distros):") | |
| for p in DOTNET_DEPS_GLOBAL: | |
| status = "✗ missing" if p in missing else "✓ installed" | |
| info(f" {status}: {p}") | |
| info(f"\nPer-distro dependencies ({pm}):") | |
| per_pm = { | |
| "apt": DOTNET_DEPS_APT, | |
| "dnf": DOTNET_DEPS_DNF, | |
| "pacman": DOTNET_DEPS_PACMAN, | |
| }.get(pm, []) | |
| for p in per_pm: | |
| status = "✗ missing" if p in missing else "✓ installed" | |
| info(f" {status}: {p}") | |
| if not missing: | |
| info("\n✓ All .NET dependencies are already installed.") | |
| return | |
| info(f"\nMissing ({len(missing)}):") | |
| for p in missing: | |
| info(f" - {p}") | |
| info("") | |
| try: | |
| answer = input("Install missing packages? [y/N] ").strip().lower() | |
| except (EOFError, KeyboardInterrupt): | |
| info("") | |
| info("Cancelled.") | |
| return | |
| if answer not in ("y", "yes"): | |
| info("Skipped.") | |
| return | |
| linux_install_packages(pm, missing) | |
| info("✓ Dependencies installed.") | |
| def cmd_unlink_brew() -> None: | |
| """ | |
| Detects if dotnet is installed via Homebrew and unlinks it to prevent | |
| conflicts with the official .NET SDK in ~/.dotnet. Idempotent. | |
| """ | |
| if not CFG.unlink_brew: | |
| return | |
| if sys.platform != "darwin": | |
| verbose_info("unlink-brew command is only applicable on macOS.") | |
| return | |
| brew_cmd = shutil.which("brew") | |
| if not brew_cmd: | |
| verbose_info("Homebrew not found on this system. Nothing to fix.") | |
| return | |
| verbose_info("Checking for Homebrew dotnet installation...") | |
| # Check if dotnet formula is installed | |
| result = run([brew_cmd, "list", "--formula"], check=False, capture=True) | |
| if result.returncode != 0: | |
| info("Unable to query Homebrew formulas.") | |
| return | |
| installed_formulas = result.stdout.lower().split() | |
| if "dotnet" not in installed_formulas and "dotnet-sdk" not in installed_formulas: | |
| verbose_info("✓ No Homebrew dotnet installation found.") | |
| return | |
| verbose_info("Found Homebrew dotnet installation.") | |
| # Check if dotnet is currently linked | |
| link_result = run( | |
| [brew_cmd, "list", "--formula", "dotnet"], check=False, capture=True | |
| ) | |
| is_installed = link_result.returncode == 0 | |
| if not is_installed: | |
| # Try dotnet-sdk as alternative name | |
| link_result = run( | |
| [brew_cmd, "list", "--formula", "dotnet-sdk"], check=False, capture=True | |
| ) | |
| is_installed = link_result.returncode == 0 | |
| dotnet_formula = "dotnet-sdk" if is_installed else "dotnet" | |
| else: | |
| dotnet_formula = "dotnet" | |
| if not is_installed: | |
| verbose_info("✓ Homebrew dotnet detected but formula status unclear.") | |
| return | |
| # Check if it's linked | |
| verbose_info(f"Checking if {dotnet_formula} is linked...") | |
| link_check = run([brew_cmd, "info", dotnet_formula], check=False, capture=True) | |
| if link_check.returncode == 0 and "not linked" in link_check.stdout.lower(): | |
| verbose_info( | |
| f"✓ Homebrew {dotnet_formula} is already unlinked. No action needed." | |
| ) | |
| return | |
| # Unlink it | |
| info(f"Unlinking Homebrew {dotnet_formula}...") | |
| unlink_result = run([brew_cmd, "unlink", dotnet_formula], check=False, capture=True) | |
| if unlink_result.returncode == 0: | |
| info(f"✓ Successfully unlinked Homebrew {dotnet_formula}.") | |
| info("") | |
| info("Your ~/.dotnet installation will now take precedence.") | |
| info(f"Verify with: which dotnet # Should show {CFG.dotnet_dir}/dotnet") | |
| else: | |
| info(f"Failed to unlink {dotnet_formula}: {unlink_result.stderr.strip()}") | |
| def cmd_list() -> None: | |
| index = fetch_releases_index() | |
| for ch, lr in get_latest_release_per_channel(index): | |
| info(f"{ch}: {lr}") | |
| def cmd_fix_signatures() -> None: | |
| """Fix macOS code signature and quarantine issues.""" | |
| if sys.platform != "darwin": | |
| info("fix-signatures command is only applicable on macOS.") | |
| return | |
| if not CFG.dotnet_dir.exists(): | |
| info(f"No .NET installation found at {CFG.dotnet_dir}") | |
| return | |
| info("Fixing macOS code signatures and quarantine issues...") | |
| macos_fix_code_signatures() | |
| info("✓ Complete. Try running: dotnet --version") | |
| def cmd_cleanup() -> None: | |
| """Cleanup old minor versions for all installed channels.""" | |
| installed = installed_channels() | |
| if not installed: | |
| info(f"No installed SDK channels found in {CFG.dotnet_dir}/sdk") | |
| return | |
| info("Cleaning up old SDK versions...") | |
| cleaned_any = False | |
| for ch in installed: | |
| versions = get_channel_versions(ch) | |
| if len(versions) > 1: | |
| info(f"Channel {ch}: {len(versions)} version(s) installed") | |
| cleanup_old_channel_versions(ch) | |
| cleaned_any = True | |
| if not cleaned_any: | |
| info( | |
| "No old versions to clean up. All channels have only one version installed." | |
| ) | |
| else: | |
| info("Cleanup complete.") | |
| def cmd_check() -> None: | |
| """Run diagnostic checks and attempt to fix common issues.""" | |
| info("Running .NET installation diagnostics...\n") | |
| issues_found = 0 | |
| fixes_applied = 0 | |
| # Check 1: Dotnet installation exists | |
| info("[1/6] Checking .NET installation...") | |
| if not CFG.dotnet_dir.exists(): | |
| info(" ✗ .NET not installed in {CFG.dotnet_dir}") | |
| info(" → Run: ./dotnet-helper.py install") | |
| issues_found += 1 | |
| elif not CFG.dotnet_path.exists(): | |
| info(f" ✗ .NET executable not found at {CFG.dotnet_path}") | |
| info(" → Run: ./dotnet-helper.py install") | |
| issues_found += 1 | |
| else: | |
| info(f" ✓ .NET installed at {CFG.dotnet_dir}") | |
| # Check 2: Shell RC configuration | |
| info("\n[2/6] Checking shell configuration...") | |
| rc_files = list(rc_files_to_patch()) | |
| if not rc_files: | |
| info(" ✗ No shell RC files found to configure") | |
| issues_found += 1 | |
| else: | |
| needs_patch = False | |
| for rc in rc_files: | |
| if rc.exists(): | |
| text = rc.read_text() | |
| if BLOCK_BEGIN not in text: | |
| needs_patch = True | |
| break | |
| if needs_patch: | |
| info(" ⚠ Shell RC files need configuration") | |
| info(" → Applying fix...") | |
| patch_rc_files() | |
| info(" ✓ Shell RC files configured") | |
| fixes_applied += 1 | |
| else: | |
| info(" ✓ Shell RC files properly configured") | |
| # Check 3: PATH configuration | |
| info("\n[3/6] Checking PATH configuration...") | |
| path_env = os.environ.get("PATH", "") | |
| dotnet_in_path = str(CFG.dotnet_dir) in path_env | |
| if dotnet_in_path: | |
| info(f" ✓ {CFG.dotnet_dir} is in PATH") | |
| else: | |
| info(f" ⚠ {CFG.dotnet_dir} not in current PATH") | |
| info(" → Reload shell: source ~/.zshrc (or your RC file)") | |
| issues_found += 1 | |
| # Check 4: Homebrew dotnet conflicts (macOS only) | |
| if sys.platform == "darwin": | |
| info("\n[4/6] Checking Homebrew conflicts...") | |
| brew_cmd = shutil.which("brew") | |
| if brew_cmd: | |
| result = run([brew_cmd, "list", "--formula"], check=False, capture=True) | |
| if result.returncode == 0: | |
| installed_formulas = result.stdout.lower().split() | |
| if "dotnet" in installed_formulas or "dotnet-sdk" in installed_formulas: | |
| info(" ⚠ Homebrew dotnet detected") | |
| if CFG.unlink_brew: | |
| info(" → Applying fix...") | |
| cmd_unlink_brew() | |
| fixes_applied += 1 | |
| else: | |
| info(" → unlink_brew is disabled in config.json") | |
| issues_found += 1 | |
| else: | |
| info(" ✓ No Homebrew dotnet conflicts") | |
| else: | |
| info(" ⚠ Could not check Homebrew formulas") | |
| else: | |
| info(" ✓ Homebrew not installed") | |
| # Additional check: Code signatures and quarantine | |
| info("\n[4.5/6] Checking code signatures...") | |
| if CFG.dotnet_path.exists(): | |
| # Try to run dotnet --version to see if it's blocked | |
| test_result = run( | |
| [str(CFG.dotnet_path), "--version"], check=False, capture=True | |
| ) | |
| if test_result.returncode != 0 and ( | |
| "killed" in test_result.stderr.lower() | |
| or "signature" in test_result.stderr.lower() | |
| ): | |
| info(" ⚠ Code signature issues detected") | |
| info(" → Fixing code signatures and quarantine...") | |
| macos_fix_code_signatures() | |
| info(" ✓ Code signatures fixed") | |
| fixes_applied += 1 | |
| else: | |
| info(" ✓ No code signature issues detected") | |
| else: | |
| info(" ⊘ Dotnet not installed yet") | |
| else: | |
| info("\n[4/6] Checking Homebrew conflicts...") | |
| info(" ⊘ Not applicable (not macOS)") | |
| # Check 5: Linux dependencies | |
| if is_linux(): | |
| info("\n[5/6] Checking Linux dependencies...") | |
| pm = linux_detect_package_manager() | |
| if pm: | |
| wanted = linux_dotnet_deps(pm) | |
| missing = [p for p in wanted if not linux_pkg_is_installed(pm, p)] | |
| if missing: | |
| info(f" ⚠ Missing dependencies: {', '.join(missing)}") | |
| info(" → Installing...") | |
| linux_install_packages(pm, missing) | |
| info(" ✓ Dependencies installed") | |
| fixes_applied += 1 | |
| else: | |
| info(" ✓ All dependencies satisfied") | |
| else: | |
| info(" ⚠ Could not detect package manager") | |
| issues_found += 1 | |
| else: | |
| info("\n[5/6] Checking Linux dependencies...") | |
| info(" ⊘ Not applicable (not Linux)") | |
| # Check 6: Old SDK versions | |
| info("\n[6/6] Checking for old SDK versions...") | |
| installed = installed_channels() | |
| if installed: | |
| old_versions_exist = False | |
| for ch in installed: | |
| versions = get_channel_versions(ch) | |
| if len(versions) > 1: | |
| old_versions_exist = True | |
| break | |
| if old_versions_exist: | |
| info(" ⚠ Multiple versions of some channels detected") | |
| if CFG.cleanup_old_versions: | |
| info(" → Run cleanup: ./dotnet-helper.py cleanup") | |
| else: | |
| info(" → cleanup_old_versions is disabled in config.json") | |
| issues_found += 1 | |
| else: | |
| info(" ✓ No old versions detected") | |
| else: | |
| info(" ⚠ No SDK channels installed") | |
| # Summary | |
| info("\n" + "=" * 50) | |
| if issues_found == 0 and fixes_applied == 0: | |
| info("✓ All checks passed! Your .NET installation is healthy.") | |
| elif fixes_applied > 0: | |
| info(f"✓ Applied {fixes_applied} fix(es). {issues_found} issue(s) remaining.") | |
| if issues_found > 0: | |
| info(" Review the checks above for remaining issues.") | |
| else: | |
| info(f"⚠ Found {issues_found} issue(s). Review the checks above.") | |
| info("=" * 50) | |
| def cmd_reinstall() -> None: | |
| """Reinstall .NET by removing ~/.dotnet and reinstalling SDKs and tools.""" | |
| info("Preparing to reinstall .NET...\n") | |
| # Collect current state | |
| installed = installed_channels() | |
| tools = get_installed_global_tools() | |
| if not installed: | |
| info("No SDK channels currently installed.") | |
| info("Nothing to reinstall. Use 'install' command instead.") | |
| return | |
| info("Current installation:") | |
| info(f" SDK channels: {', '.join(installed)}") | |
| if tools: | |
| info(f" Global tools: {', '.join(tools)}") | |
| else: | |
| info(" Global tools: none") | |
| # Confirm | |
| if is_tty(): | |
| info("") | |
| response = input("Proceed with reinstall? This will remove ~/.dotnet [y/N]: ") | |
| if response.lower().strip() not in ("y", "yes"): | |
| info("Reinstall cancelled.") | |
| return | |
| else: | |
| info("\nRunning in non-interactive mode. Proceeding with reinstall...") | |
| info("") | |
| info("Step 1: Removing .NET installation...") | |
| if CFG.dotnet_dir.exists(): | |
| shutil.rmtree(CFG.dotnet_dir, ignore_errors=True) | |
| if INSTALL_SCRIPT.exists(): | |
| try: | |
| INSTALL_SCRIPT.unlink() | |
| except Exception: | |
| pass | |
| info(" ✓ Removed") | |
| info("") | |
| info("Step 2: Reinstalling SDK channels...") | |
| install_or_update_channels(installed) | |
| if tools: | |
| info("") | |
| info("Step 3: Reinstalling global tools...") | |
| dn = CFG.dotnet_cmd() | |
| for tool_id in tools: | |
| info(f" Installing {tool_id}...") | |
| run([dn, "tool", "install", "-g", tool_id], check=False) | |
| info("") | |
| info("=" * 50) | |
| info("✓ Reinstall complete!") | |
| info("=" * 50) | |
| 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() | |
| installed = installed_channels() | |
| selected = select_channels_curses(channels, installed) | |
| # Uninstall channels that were deselected | |
| to_uninstall = [ch for ch in installed if ch not in selected] | |
| if to_uninstall: | |
| uninstall_channels(to_uninstall) | |
| # Install/update selected channels | |
| if selected: | |
| 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} [-f] [-v] # interactive selection | |
| {exe} list # show latest release per channel | |
| {exe} [-f] [-v] update # update installed channels | |
| {exe} uninstall # uninstall dotnet from ~/.dotnet | |
| {exe} [-f] [-v] install [CH...] # install/update; no args installs latest stable | |
| {exe} unlink-brew # unlink Homebrew dotnet (macOS only) | |
| {exe} cleanup # remove old minor versions, keep latest only | |
| {exe} check # run diagnostics and fix common issues | |
| {exe} reinstall # clean reinstall (preserves SDK/tool list) | |
| {exe} fix-signatures # fix macOS code signatures (if dotnet crashes) | |
| {exe} deps # check and install missing .NET dependencies (Linux only) | |
| Flags: | |
| -f, --force reinstall even when the latest SDK is already present | |
| -v, --verbose show detailed output | |
| """ | |
| ) | |
| def main() -> None: | |
| global FORCE, VERBOSE | |
| load_config() | |
| argv = sys.argv[1:] | |
| if argv and argv[0] in ("-h", "--help", "help"): | |
| usage() | |
| return | |
| FORCE = any(a in ("-f", "--force") for a in argv) | |
| VERBOSE = CFG.verbose or any(a in ("-v", "--verbose") for a in argv) | |
| argv = [a for a in argv if a not in ("-f", "--force", "-v", "--verbose")] | |
| 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) | |
| elif cmd == "unlink-brew": | |
| cmd_unlink_brew() | |
| elif cmd == "cleanup": | |
| cmd_cleanup() | |
| elif cmd == "check": | |
| cmd_check() | |
| elif cmd == "reinstall": | |
| cmd_reinstall() | |
| elif cmd == "fix-signatures": | |
| cmd_fix_signatures() | |
| elif cmd == "deps": | |
| cmd_deps() | |
| 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