Skip to content

Instantly share code, notes, and snippets.

@FalconNL93
Created January 24, 2026 09:13
Show Gist options
  • Select an option

  • Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.

Select an option

Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.
{
"min_major": 5,
"dotnet_dir": "~/.dotnet",
"tools": [
"dotnet-ef",
"csharpier",
"dotnet-outdated-tool"
],
"workloads": {
"update": true,
"from_previous_sdk": true
}
}
#!/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