diff --git a/README.md b/README.md index b260f10..2704380 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,69 @@ # LXS -Linux multi-tool — a Bash menu and CLI for installing common server apps and running system tools on Debian/Ubuntu. +Multi-machine toolbox — one menu, two platforms. A Bash entrypoint for +Debian/Ubuntu servers and a PowerShell entrypoint for Windows workstations, +sharing the same interface, the same install flow and the same update +mechanism. -## Quick install +``` +lxs.sh → linux/ server apps, hardening, benchmarks, MOTD +lxs.ps1 → windows/ diagnostics, cleanup, hardening, debloat, winget apps +VERSION shared by both +``` + +## Install + +### Linux (Debian / Ubuntu) ```bash curl -fsSL https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.sh | sudo bash -s -- setup ``` -This downloads the repo tarball, installs every file to `/usr/local/share/lxs/`, and creates the symlink `/usr/local/bin/lxs`. After install, sub-scripts run from disk — no network calls per command. +Installs every file to `/usr/local/share/lxs/` and creates the symlink +`/usr/local/bin/lxs`. After install, sub-scripts run from disk — no network +calls per command. -> Avoid `sudo bash <(curl …)` — process substitution opens `/dev/fd/63` in your shell, which the root `bash` spawned by `sudo` cannot read (`bash: /dev/fd/63: No such file or directory`). Pipe through `sudo bash -s --` instead. +> Avoid `sudo bash <(curl …)` — process substitution opens `/dev/fd/63` in your +> shell, which the root `bash` spawned by `sudo` cannot read +> (`bash: /dev/fd/63: No such file or directory`). Pipe through +> `sudo bash -s --` instead. -Or run once without installing (no `sudo` needed for the entrypoint — sub-scripts auto-elevate when they need root): +Or run once without installing (no `sudo` needed for the entrypoint — +sub-scripts auto-elevate when they need root): ```bash curl -fsSL https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.sh | bash ``` -When a new version is published in this repo, the interactive menu shows a *"Nouvelle version disponible"* banner; run `lxs update` to pull all updated files. +### Windows (10 1809+ / 11 / Server 2019+) + +In PowerShell: + +```powershell +& ([scriptblock]::Create((irm https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.ps1))) setup +``` + +Installs to `%LOCALAPPDATA%\lxs`, adds `%LOCALAPPDATA%\lxs\bin` to your **user** +PATH and drops an `lxs.cmd` shim there — so the install itself never needs +administrator rights. Open a new terminal afterwards and run `lxs`. Tools that +change the machine request elevation on their own, in a separate window. + +Or open the menu once without installing: + +```powershell +irm https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.ps1 | iex +``` + +Windows PowerShell 5.1 (preinstalled on every supported Windows) is enough; +PowerShell 7 works too. ## Usage -```bash +The same verbs on both platforms: + +``` lxs # Interactive menu (checks for updates, cached 24h) -lxs setup # First-time install of all files to /usr/local/share/lxs +lxs setup # First-time install lxs update # Update all installed files to latest lxs install # Install an application lxs tool [args] # Run a system tool @@ -33,11 +72,68 @@ lxs version # Show version lxs help # Show help ``` +When a new version is published in this repo, the interactive menu shows an +`UPDATE_AVAILABLE` banner; run `lxs update` to pull all updated files. + +## What is in the box + +### Linux — `lxs install ` + +| Name | What it installs | +|---|---| +| `coolify` | Coolify self-hosted PaaS | +| `pterodactyl` | Pterodactyl game server panel | +| `uptime-kuma` | Uptime Kuma monitoring | +| `cloudpanel` | CloudPanel server control panel | +| `proxmox` | Proxmox VE post-install tools | + +### Linux — `lxs tool ` + +| Name | What it does | +|---|---| +| `system` | System information and diagnostics | +| `benchmark` | CPU, disk and network benchmark | +| `harden` | UFW, fail2ban, unattended-upgrades | +| `root-password` | Change the root password | +| `update` | apt update / upgrade / autoremove | +| `root-ssh-login` | Toggle root SSH password login | +| `welcome`, `motd` | Custom MOTD banner | + +### Windows — `lxs install ` + +| Name | What it installs (via winget) | +|---|---| +| `dev` | Git, VS Code, Node.js, Python, PowerShell 7, Windows Terminal, WSL 2 | +| `utilities` | 7-Zip, Notepad++, PowerToys, Sysinternals, Everything, ShareX | +| `browsers` | LibreWolf, Firefox, Brave, Chrome, VLC, OBS Studio | +| `containers` | WSL 2, Hyper-V, Docker Desktop, VirtualBox | +| `claude-code` | Claude Code CLI (+ Git for Windows) | + +### Windows — `lxs tool ` + +| Name | What it does | +|---|---| +| `system` | Hardware, OS, disks, memory, event logs, SMART, activation | +| `network` | IP config, listening ports, DNS flush, ping/traceroute, stack reset | +| `cleanup` | Temp files, Windows Update cache, recycle bin, Prefetch, WinSxS | +| `repair` | SFC, DISM RestoreHealth, chkdsk | +| `restore-point` | Enable System Protection, create/list/roll back checkpoints | +| `update` | Windows Update (built-in COM API) + `winget upgrade --all` | +| `harden` | Firewall, SMBv1 off, UAC, Defender, LLMNR/NetBIOS, account audit | +| `remote-access` | Remote Desktop (with NLA) and the OpenSSH server | +| `debloat` | Remove preinstalled apps you pick, disable telemetry/ads/Copilot | + +The Windows tools that change the system create a **restore point first**, +show exactly what they will change and ask before doing it. `debloat` never +removes an app you have not selected by number, and its registry, service and +scheduled-task changes can be reverted from its own menu. + ## Requirements -- Debian or Ubuntu (other distros may work but are not tested) -- `curl` -- Some sub-scripts require root; they will auto-elevate via `sudo` when run. +- **Linux**: Debian or Ubuntu (other distros may work but are not tested), + `curl`. Some sub-scripts require root and auto-elevate via `sudo`. +- **Windows**: Windows 10 1809+, Windows 11 or Server 2019+. `winget` + (App Installer) is needed for the application installers only. ## License diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/linux/apps/cloudpanel.sh b/linux/apps/cloudpanel.sh index c439869..cd2440d 100755 --- a/linux/apps/cloudpanel.sh +++ b/linux/apps/cloudpanel.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_cloudpanel.log" # ═══════════════════════════════════════════════════════════════════════════ diff --git a/linux/apps/coolify.sh b/linux/apps/coolify.sh index ef31432..434a329 100755 --- a/linux/apps/coolify.sh +++ b/linux/apps/coolify.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_coolify.log" require_root "$0" "$@" diff --git a/linux/apps/index.sh b/linux/apps/index.sh index efadca9..7b236ca 100755 --- a/linux/apps/index.sh +++ b/linux/apps/index.sh @@ -6,13 +6,29 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self # Run a sibling app script. Prefers a file next to this script (installed -# layout); falls back to downloading from LXS_RAW_BASE. +# layout); falls back to downloading from LXS_RAW_PLATFORM_BASE. run_sibling() { local script_path=$1 shift @@ -35,7 +51,7 @@ run_sibling() { local temp_file exit_code temp_file=$(mktemp "/tmp/lxs.${script_name%.*}.XXXXXX.sh") echo -e "${CYAN}[..] Fetching ${BOLD}${script_name}${NC}${CYAN}...${NC}" - if curl -fsSL -H "Cache-Control: no-cache" -o "${temp_file}" "${LXS_RAW_BASE}/${script_path}"; then + if curl -fsSL -H "Cache-Control: no-cache" -o "${temp_file}" "${LXS_RAW_PLATFORM_BASE}/${script_path}"; then echo -e "${GREEN}[OK] Payload acquired${NC}" chmod +x "${temp_file}" "${temp_file}" "$@" diff --git a/linux/apps/proxmox.sh b/linux/apps/proxmox.sh index 6155d1f..0784b00 100755 --- a/linux/apps/proxmox.sh +++ b/linux/apps/proxmox.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_proxmox.log" require_root "$0" "$@" diff --git a/linux/apps/pterodactyl.sh b/linux/apps/pterodactyl.sh index c4f8ab2..0209672 100755 --- a/linux/apps/pterodactyl.sh +++ b/linux/apps/pterodactyl.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_pterodactyl.log" require_root "$0" "$@" diff --git a/linux/apps/uptime-kuma.sh b/linux/apps/uptime-kuma.sh index 6f64aab..da020b8 100755 --- a/linux/apps/uptime-kuma.sh +++ b/linux/apps/uptime-kuma.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_uptime_kuma.log" require_root "$0" "$@" diff --git a/linux/lxs.sh b/linux/lxs.sh index 29ebe62..2a86800 100755 --- a/linux/lxs.sh +++ b/linux/lxs.sh @@ -17,8 +17,8 @@ if [ -n "${BASH_SOURCE[0]:-}" ]; then LXS_SCRIPT_DIR=$(dirname "$_lxs_resolved") unset _lxs_resolved fi -if [ -n "$LXS_SCRIPT_DIR" ] && [ -r "${LXS_SCRIPT_DIR}/VERSION" ]; then - LXS_VERSION=$(head -n1 "${LXS_SCRIPT_DIR}/VERSION" 2>/dev/null | tr -d '[:space:]') +if [ -n "$LXS_SCRIPT_DIR" ] && [ -r "${LXS_SCRIPT_DIR}/../VERSION" ]; then + LXS_VERSION=$(head -n1 "${LXS_SCRIPT_DIR}/../VERSION" 2>/dev/null | tr -d '[:space:]') fi LXS_VERSION="${LXS_VERSION:-dev}" @@ -27,6 +27,12 @@ LXS_BRANCH="${LXS_BRANCH:-main}" export LXS_RAW_BASE="${LXS_RAW_BASE:-${LXS_REPO_URL}/raw/branch/${LXS_BRANCH}}" LXS_TARBALL_URL="${LXS_TARBALL_URL:-${LXS_REPO_URL}/archive/${LXS_BRANCH}.tar.gz}" +# Platform sub-tree inside the repo. Every apps/ and tools/ path is resolved +# under it, both on disk (${LXS_INSTALL_DIR}/linux/...) and remotely +# (${LXS_RAW_BASE}/linux/...). A future macos/ tree only needs this to change. +LXS_PLATFORM_DIR="linux" +export LXS_RAW_PLATFORM_BASE="${LXS_RAW_BASE}/${LXS_PLATFORM_DIR}" + LXS_INSTALL_DIR="${LXS_INSTALL_DIR:-/usr/local/share/lxs}" LXS_BIN_PATH="${LXS_BIN_PATH:-/usr/local/bin/lxs}" LXS_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/lxs" @@ -43,7 +49,7 @@ if [ -n "$LXS_SCRIPT_DIR" ] && [ -r "${LXS_SCRIPT_DIR}/lib/common.sh" ]; then # shellcheck disable=SC1091 . "${LXS_SCRIPT_DIR}/lib/common.sh" else - _lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2 exit 1 } @@ -67,12 +73,6 @@ lxs_self_path() { echo "$src" } -lxs_is_installed() { - local self - self=$(lxs_self_path) || return 1 - [ "$(dirname "$self")" = "$LXS_INSTALL_DIR" ] -} - lxs_need_root_for() { local path=$1 local parent @@ -167,9 +167,14 @@ download_and_run() { shift local script_name local_path script_name=$(basename "$script_path") - local_path="${LXS_INSTALL_DIR}/${script_path}" - if lxs_is_installed && [ -f "$local_path" ]; then + # Prefer the copy sitting next to this script, whatever the prefix: that + # covers the installed layout, a git checkout, and a custom + # LXS_INSTALL_DIR alike. Only a `curl | bash` run has no siblings on disk. + local_path="" + [ -n "$LXS_SCRIPT_DIR" ] && local_path="${LXS_SCRIPT_DIR}/${script_path}" + + if [ -n "$local_path" ] && [ -f "$local_path" ]; then chmod +x "$local_path" 2>/dev/null || true echo "" show_separator @@ -194,7 +199,7 @@ download_and_run() { if curl -fsSL -H "Cache-Control: no-cache" \ -o "${temp_file}" \ - "${LXS_RAW_BASE}/${script_path}"; then + "${LXS_RAW_PLATFORM_BASE}/${script_path}"; then echo -e "${GREEN}[OK] Payload acquired${NC}" chmod +x "${temp_file}" @@ -217,7 +222,7 @@ download_and_run() { return $exit_code else echo -e "${RED}[KO] Failed to download ${script_path}${NC}" - echo -e "${RED} URL: ${LXS_RAW_BASE}/${script_path}${NC}" + echo -e "${RED} URL: ${LXS_RAW_PLATFORM_BASE}/${script_path}${NC}" rm -f "${temp_file}" return 1 fi @@ -309,8 +314,8 @@ lxs_sync_install() { return 1 fi - if [ ! -f "${work}/extracted/lxs.sh" ] || [ ! -f "${work}/extracted/VERSION" ]; then - echo -e "${RED}[KO] Tarball is missing lxs.sh or VERSION${NC}" + if [ ! -f "${work}/extracted/${LXS_PLATFORM_DIR}/lxs.sh" ] || [ ! -f "${work}/extracted/VERSION" ]; then + echo -e "${RED}[KO] Tarball is missing ${LXS_PLATFORM_DIR}/lxs.sh or VERSION${NC}" return 1 fi @@ -334,8 +339,8 @@ lxs_sync_install() { rm -rf "${LXS_INSTALL_DIR}.old" fi - chmod +x "${LXS_INSTALL_DIR}/lxs.sh" 2>/dev/null || true - find "${LXS_INSTALL_DIR}/apps" "${LXS_INSTALL_DIR}/tools" -name '*.sh' -exec chmod +x {} + 2>/dev/null || true + chmod +x "${LXS_INSTALL_DIR}/lxs.sh" "${LXS_INSTALL_DIR}/${LXS_PLATFORM_DIR}/lxs.sh" 2>/dev/null || true + find "${LXS_INSTALL_DIR}/${LXS_PLATFORM_DIR}" -name '*.sh' -exec chmod +x {} + 2>/dev/null || true if [ -e "$LXS_BIN_PATH" ] || [ -L "$LXS_BIN_PATH" ]; then rm -f "$LXS_BIN_PATH" diff --git a/linux/tools/harden.sh b/linux/tools/harden.sh index 0cbb333..cbedb4e 100755 --- a/linux/tools/harden.sh +++ b/linux/tools/harden.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_harden.log" require_root "$0" "$@" diff --git a/linux/tools/index.sh b/linux/tools/index.sh index b390b51..945ceb1 100755 --- a/linux/tools/index.sh +++ b/linux/tools/index.sh @@ -6,13 +6,29 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self # Run a sibling tool script. Prefers a file next to this script (installed -# layout); falls back to downloading from LXS_RAW_BASE. +# layout); falls back to downloading from LXS_RAW_PLATFORM_BASE. run_sibling() { local script_path=$1 shift @@ -36,7 +52,7 @@ run_sibling() { local temp_file exit_code temp_file=$(mktemp "/tmp/lxs.${script_name%.*}.XXXXXX.sh") echo -e "${CYAN}[..] Fetching ${BOLD}${script_name}${NC}${CYAN}...${NC}" - if curl -fsSL -H "Cache-Control: no-cache" -o "${temp_file}" "${LXS_RAW_BASE}/${script_path}"; then + if curl -fsSL -H "Cache-Control: no-cache" -o "${temp_file}" "${LXS_RAW_PLATFORM_BASE}/${script_path}"; then echo -e "${GREEN}[OK] Payload acquired${NC}" chmod +x "${temp_file}" "${temp_file}" "$@" diff --git a/linux/tools/root-password.sh b/linux/tools/root-password.sh index 365530d..e0a704e 100755 --- a/linux/tools/root-password.sh +++ b/linux/tools/root-password.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_root_password.log" require_root "$0" "$@" diff --git a/linux/tools/root-ssh-login.sh b/linux/tools/root-ssh-login.sh index da41bef..55a521a 100755 --- a/linux/tools/root-ssh-login.sh +++ b/linux/tools/root-ssh-login.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_root_ssh_login.log" require_root "$0" "$@" diff --git a/linux/tools/server-benchmark.sh b/linux/tools/server-benchmark.sh index 6c76e26..c844260 100755 --- a/linux/tools/server-benchmark.sh +++ b/linux/tools/server-benchmark.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_benchmark.log" # Always clean up benchmark artifacts on exit, even on error or Ctrl+C. Without diff --git a/linux/tools/system-infos.sh b/linux/tools/system-infos.sh index 4bee5a7..13206e3 100755 --- a/linux/tools/system-infos.sh +++ b/linux/tools/system-infos.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_system_infos.log" # Menu: System Infos diff --git a/linux/tools/update-server.sh b/linux/tools/update-server.sh index bf105fd..41f607e 100755 --- a/linux/tools/update-server.sh +++ b/linux/tools/update-server.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_update_server.log" require_root "$0" "$@" diff --git a/linux/tools/welcome-message.sh b/linux/tools/welcome-message.sh index 6d7bd85..0944eee 100755 --- a/linux/tools/welcome-message.sh +++ b/linux/tools/welcome-message.sh @@ -6,10 +6,26 @@ # Date: 2025 # Load LXS common library (colors, separator, run_spinner, loggers, helpers) +# Prefers the sibling ../lib/common.sh (repo checkout or installed layout) and +# only hits the network when this script is run standalone via curl. LXS_RAW_BASE="${LXS_RAW_BASE:-https://git.hyko.cx/hykocx/lxs/raw/branch/main}" -_lib=$(curl -fsSL "${LXS_RAW_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } -eval "$_lib" -unset _lib +LXS_RAW_PLATFORM_BASE="${LXS_RAW_PLATFORM_BASE:-${LXS_RAW_BASE}/linux}" +_lxs_lib="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_self=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_self="${BASH_SOURCE[0]}" + _lxs_lib="$(dirname "$_lxs_self")/../lib/common.sh" +fi +if [ -n "$_lxs_lib" ] && [ -r "$_lxs_lib" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_lxs_lib" +else + _lib=$(curl -fsSL "${LXS_RAW_PLATFORM_BASE}/lib/common.sh") || { echo "Failed to fetch lib/common.sh" >&2; exit 1; } + eval "$_lib" + unset _lib +fi +unset _lxs_lib _lxs_self export LXS_LOG_FILE="/tmp/lxs_welcome_message.log" require_root "$0" "$@" diff --git a/lxs.ps1 b/lxs.ps1 new file mode 100644 index 0000000..254df0b --- /dev/null +++ b/lxs.ps1 @@ -0,0 +1,60 @@ +<# + LXS - platform dispatcher (Windows side) + + Thin shim kept at the repo root so the documented one-liner stays short and + stable while the real implementation lives in windows\lxs.ps1: + + irm https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.ps1 | iex + & ([scriptblock]::Create((irm https://git.hyko.cx/hykocx/lxs/raw/branch/main/lxs.ps1))) setup + + Repo: https://git.hyko.cx/hykocx/lxs + License: MIT +#> + +$LxsShimArgs = @($args) + +$LxsRepoUrl = 'https://git.hyko.cx/hykocx/lxs' +$LxsBranch = if ($env:LXS_BRANCH) { $env:LXS_BRANCH } else { 'main' } +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { "$LxsRepoUrl/raw/branch/$LxsBranch" } +$LxsPlatformDir = 'windows' + +# Running this shim under pwsh on Linux/macOS is a mistake worth catching. +if ($PSVersionTable.PSVersion.Major -ge 6 -and + -not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows)) { + Write-Error 'lxs.ps1 is the Windows entrypoint. On Linux or macOS, use lxs.sh instead.' + exit 1 +} + +# Locate a sibling windows\lxs.ps1 (repo checkout or installed layout). +$LxsSelfDir = $PSScriptRoot +if (-not $LxsSelfDir -and $MyInvocation.MyCommand.Path) { + $LxsSelfDir = Split-Path -Parent $MyInvocation.MyCommand.Path +} + +if ($LxsSelfDir) { + $local = Join-Path $LxsSelfDir "$LxsPlatformDir\lxs.ps1" + if (Test-Path $local) { + & $local @LxsShimArgs + exit $LASTEXITCODE + } +} + +# No files on disk — we were piped from irm. Fetch the platform entrypoint. +$temp = Join-Path $env:TEMP ("lxs.entrypoint.{0}.ps1" -f [guid]::NewGuid().ToString('N').Substring(0, 8)) +try { + Invoke-WebRequest -Uri "$LxsRawBase/$LxsPlatformDir/lxs.ps1" -OutFile $temp ` + -UseBasicParsing -Headers @{ 'Cache-Control' = 'no-cache' } -ErrorAction Stop +} catch { + Write-Error "lxs: failed to fetch $LxsRawBase/$LxsPlatformDir/lxs.ps1" + exit 1 +} + +try { + $global:LASTEXITCODE = 0 + & $temp @LxsShimArgs + $code = $LASTEXITCODE +} finally { + Remove-Item $temp -Force -ErrorAction SilentlyContinue +} +exit $code diff --git a/lxs.sh b/lxs.sh new file mode 100755 index 0000000..39052b5 --- /dev/null +++ b/lxs.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# LXS - platform dispatcher (POSIX side) +# Description: Thin shim kept at the repo root so the documented install URL +# and the /usr/local/bin/lxs symlink stay stable while the real +# implementation lives in linux/lxs.sh. +# Repo: https://git.hyko.cx/hykocx/lxs +# License: MIT + +LXS_REPO_URL="${LXS_REPO_URL:-https://git.hyko.cx/hykocx/lxs}" +LXS_BRANCH="${LXS_BRANCH:-main}" +export LXS_RAW_BASE="${LXS_RAW_BASE:-${LXS_REPO_URL}/raw/branch/${LXS_BRANCH}}" + +# Platform sub-tree to dispatch into. Only linux/ ships today; the darwin +# branch is here so a macos/ tree drops in without touching this file. +case "$(uname -s 2>/dev/null)" in + Linux) LXS_PLATFORM_DIR="linux" ;; + Darwin) LXS_PLATFORM_DIR="macos" ;; + *) LXS_PLATFORM_DIR="linux" ;; +esac + +# Locate a sibling /lxs.sh (repo checkout or installed layout). +_lxs_self_dir="" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _lxs_resolved=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_resolved=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) \ + || _lxs_resolved="${BASH_SOURCE[0]}" + [ -f "$_lxs_resolved" ] && _lxs_self_dir=$(dirname "$_lxs_resolved") + unset _lxs_resolved +fi + +if [ -n "$_lxs_self_dir" ] && [ -f "${_lxs_self_dir}/${LXS_PLATFORM_DIR}/lxs.sh" ]; then + exec bash "${_lxs_self_dir}/${LXS_PLATFORM_DIR}/lxs.sh" "$@" +fi + +# No files on disk — we were piped from curl. Fetch the platform entrypoint. +if ! command -v curl >/dev/null 2>&1; then + echo "lxs: curl is required to bootstrap ${LXS_PLATFORM_DIR}/lxs.sh" >&2 + exit 1 +fi + +_lxs_tmp=$(mktemp "/tmp/lxs.entrypoint.XXXXXX.sh") || exit 1 +if ! curl -fsSL -H "Cache-Control: no-cache" \ + -o "$_lxs_tmp" "${LXS_RAW_BASE}/${LXS_PLATFORM_DIR}/lxs.sh"; then + echo "lxs: failed to fetch ${LXS_RAW_BASE}/${LXS_PLATFORM_DIR}/lxs.sh" >&2 + rm -f "$_lxs_tmp" + exit 1 +fi + +bash "$_lxs_tmp" "$@" +_lxs_rc=$? +rm -f "$_lxs_tmp" +exit $_lxs_rc diff --git a/windows/apps/browsers-media.ps1 b/windows/apps/browsers-media.ps1 new file mode 100644 index 0000000..2118ea0 --- /dev/null +++ b/windows/apps/browsers-media.ps1 @@ -0,0 +1,40 @@ +<# + LXS - Browsers and media (Windows) + Description: Browsers and media players installed through winget. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_browsers.log' + +if (-not (Assert-LxsWindows)) { exit 1 } + +$LxsBrowserPackages = @( + @{ Name = 'LibreWolf'; Id = 'LibreWolf.LibreWolf' } + @{ Name = 'Firefox'; Id = 'Mozilla.Firefox' } + @{ Name = 'Brave'; Id = 'Brave.Brave' } + @{ Name = 'Chrome'; Id = 'Google.Chrome' } + @{ Name = 'VLC'; Id = 'VideoLAN.VLC' } + @{ Name = 'OBS Studio'; Id = 'OBSProject.OBSStudio' } +) + +Invoke-LxsPackageMenu -Title 'BROWSERS & MEDIA' -Packages $LxsBrowserPackages ` + -Note 'LibreWolf is a hardened Firefox fork with telemetry stripped out.' | Out-Null +exit 0 diff --git a/windows/apps/claude-code.ps1 b/windows/apps/claude-code.ps1 new file mode 100644 index 0000000..b63b1bc --- /dev/null +++ b/windows/apps/claude-code.ps1 @@ -0,0 +1,202 @@ +<# + LXS - Claude Code (Windows) + Description: Install and check the Claude Code CLI. + + Two supported install methods, both official: + - winget (Anthropic.ClaudeCode) — does not auto-update + - the native installer from claude.ai — updates itself in the background + + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_claude_code.log' + +if (-not (Assert-LxsWindows)) { exit 1 } + +$LxsClaudeInstallerUrl = 'https://claude.ai/install.ps1' + +function Get-LxsClaudeCommand { + return (Get-Command claude -ErrorAction SilentlyContinue) +} + +function Show-LxsClaudeStatus { + Clear-Host + Show-LxsBoxTop -Title 'CLAUDE CODE STATUS' + Write-Host '' + $cmd = Get-LxsClaudeCommand + if (-not $cmd) { + Write-Host " $($script:Gray)claude is not on your PATH$($script:NC)" + Write-Host '' + Write-Host "$($script:Gray)If you just installed it, open a new terminal so the PATH change$($script:NC)" + Write-Host "$($script:Gray)takes effect.$($script:NC)" + Write-Host '' + return + } + Write-LxsOk "Found: $($cmd.Source)" + Write-Host '' + & claude --version + Write-Host '' + Show-LxsSeparator + Write-Host '' + Write-LxsInfo 'Running claude doctor (read-only diagnostics)...' + Write-Host '' + & claude doctor +} + +function Install-LxsClaudeWinget { + Clear-Host + Show-LxsBoxTop -Title 'INSTALL VIA WINGET' + Write-Host '' + Write-Host 'Installs the Anthropic.ClaudeCode package.' + Write-Host '' + Write-Host "$($script:Gray)winget installs do not update themselves — run option 5, or$($script:NC)" + Write-Host "$($script:Gray)$('winget upgrade Anthropic.ClaudeCode'), to get new versions.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Install Claude Code with winget?' -DefaultYes)) { + Write-LxsInfo 'Cancelled.' + return + } + Write-Host '' + if (Install-LxsWingetPackage -Id 'Anthropic.ClaudeCode' -Name 'Claude Code') { + Write-Host '' + Write-LxsOk 'Open a new terminal, then run: claude' + } +} + +function Install-LxsClaudeNative { + Clear-Host + Show-LxsBoxTop -Title 'INSTALL VIA THE NATIVE INSTALLER' + Write-Host '' + Write-Host 'This is the install method Anthropic documents as recommended.' + Write-Host 'It downloads and runs a script from claude.ai:' + Write-Host '' + Write-Host " $($script:White)irm $LxsClaudeInstallerUrl | iex$($script:NC)" + Write-Host '' + Write-Host "$($script:Gray)It installs into your user profile (no admin needed) and keeps$($script:NC)" + Write-Host "$($script:Gray)itself updated in the background.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Download and run that installer now?')) { + Write-LxsInfo 'Cancelled.' + return + } + + Write-Host '' + Write-LxsInfo "Fetching $LxsClaudeInstallerUrl..." + try { + $installer = Invoke-RestMethod -Uri $LxsClaudeInstallerUrl -UseBasicParsing -ErrorAction Stop + } catch { + Write-LxsErr "Download failed: $($_.Exception.Message)" + return + } + Write-LxsOk 'Installer downloaded' + Write-Host '' + Show-LxsSeparator + Write-Host '' + try { + & ([scriptblock]::Create($installer)) + } catch { + Write-LxsErr "Installer failed: $($_.Exception.Message)" + return + } + Write-Host '' + Show-LxsSeparator + Write-Host '' + Write-LxsOk 'Open a new terminal, then run: claude' +} + +function Install-LxsGitForWindows { + Clear-Host + Show-LxsBoxTop -Title 'GIT FOR WINDOWS' + Write-Host '' + Write-Host 'Optional but recommended alongside Claude Code: with Git for Windows' + Write-Host 'installed, Claude Code uses Git Bash for its Bash tool. Without it,' + Write-Host 'it falls back to the PowerShell tool.' + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Install Git for Windows?' -DefaultYes)) { + Write-LxsInfo 'Cancelled.' + return + } + Write-Host '' + Install-LxsWingetPackage -Id 'Git.Git' -Name 'Git for Windows' | Out-Null +} + +function Update-LxsClaudeCode { + Clear-Host + Show-LxsBoxTop -Title 'UPDATE CLAUDE CODE' + Write-Host '' + if (-not (Get-LxsClaudeCommand)) { + Write-LxsErr 'claude is not installed (or not on your PATH).' + return + } + + # A native install updates itself; a winget install needs winget. Trying + # `claude update` first covers the native case and reports the right thing + # for the winget case. + Write-LxsInfo 'Asking Claude Code to update itself...' + Write-Host '' + & claude update + Write-Host '' + Show-LxsSeparator + Write-Host '' + if ((Test-LxsWinget) -and (Test-LxsPackageInstalled -Id 'Anthropic.ClaudeCode')) { + Write-LxsInfo 'This looks like a winget install; upgrading through winget too...' + Write-Host '' + & winget upgrade --id Anthropic.ClaudeCode --exact --silent ` + --accept-package-agreements --accept-source-agreements + Write-Host '' + Write-Host "$($script:Gray)A winget upgrade can fail while Claude Code is running, because$($script:NC)" + Write-Host "$($script:Gray)Windows locks the executable. Close it and retry if that happens.$($script:NC)" + } +} + +function Show-LxsClaudeMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'CLAUDE CODE' + Write-Host '' + Show-LxsMenuItem '1' 'Show status' 'version + claude doctor' + Show-LxsMenuItem '2' 'Install (native)' 'auto-updating, recommended' + Show-LxsMenuItem '3' 'Install (winget)' 'manual updates' + Show-LxsMenuItem '4' 'Install Git for Windows' 'enables the Bash tool' + Show-LxsMenuItem '5' 'Update Claude Code' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + Write-Host "$($script:Gray) Claude Code needs a Pro, Max, Team, Enterprise or Console account.$($script:NC)" + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsClaudeStatus; Read-LxsEnter } + '2' { Install-LxsClaudeNative; Read-LxsEnter } + '3' { Install-LxsClaudeWinget; Read-LxsEnter } + '4' { Install-LxsGitForWindows; Read-LxsEnter } + '5' { Update-LxsClaudeCode; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-5.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsClaudeMenu +exit 75 diff --git a/windows/apps/containers.ps1 b/windows/apps/containers.ps1 new file mode 100644 index 0000000..b61b770 --- /dev/null +++ b/windows/apps/containers.ps1 @@ -0,0 +1,151 @@ +<# + LXS - Containers and virtualization (Windows) + Description: WSL 2, Hyper-V, Docker Desktop and VirtualBox. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_containers.log' + +if (-not (Assert-LxsWindows)) { exit 1 } + +$LxsContainerPackages = @( + @{ Name = 'Docker Desktop'; Id = 'Docker.DockerDesktop' } + @{ Name = 'VirtualBox'; Id = 'Oracle.VirtualBox' } +) + +function Show-LxsVirtualizationStatus { + Clear-Host + Show-LxsBoxTop -Title 'VIRTUALIZATION STATUS' + Write-Host '' + + try { + $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + if ($cs.HypervisorPresent) { + Write-LxsOk 'A hypervisor is running (Hyper-V, WSL 2 or another VMM)' + } else { + $proc = Get-CimInstance Win32_Processor -ErrorAction Stop | Select-Object -First 1 + if ($proc.VirtualizationFirmwareEnabled) { + Write-LxsOk 'Hardware virtualization is enabled in firmware, no hypervisor running' + } else { + Write-LxsWarn 'Hardware virtualization looks DISABLED in the BIOS/UEFI.' + Write-Host "$($script:Gray) Enable Intel VT-x / AMD-V there, or Docker and WSL 2 will not start.$($script:NC)" + } + } + } catch { + Write-LxsWarn 'Could not read the virtualization state.' + } + + Write-Host '' + foreach ($feature in @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform', 'Microsoft-Hyper-V-All')) { + try { + $f = Get-WindowsOptionalFeature -Online -FeatureName $feature -ErrorAction Stop + $color = if ($f.State -eq 'Enabled') { $script:Cyan } else { $script:Gray } + Write-Host (" {0,-38} $color{1}$($script:NC)" -f $feature, $f.State) + } catch { + Write-Host (" {0,-38} $($script:Gray)not available on this edition$($script:NC)" -f $feature) + } + } + + Write-Host '' + if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + Write-Host "$($script:Cyan)Installed WSL distributions:$($script:NC)" + & wsl.exe --list --verbose 2>&1 | Out-String | Write-Host + } +} + +function Install-LxsWsl { + Clear-Host + Show-LxsBoxTop -Title 'WSL 2' + Write-Host '' + Write-Host 'Enables WSL and the Virtual Machine Platform, then installs the' + Write-Host "default distribution. A $($script:Bold)reboot is required$($script:NC) afterwards." + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Install WSL 2?')) { Write-LxsInfo 'Cancelled.'; return } + if (-not (Test-LxsAdmin)) { + Write-LxsErr 'Installing WSL requires administrator rights.' + return + } + Write-Host '' + & wsl.exe --install + if ($LASTEXITCODE -eq 0) { + Write-LxsOk 'WSL install started — reboot to finish' + } else { + Write-LxsWarn "wsl --install exited with code $LASTEXITCODE" + } +} + +function Enable-LxsHyperV { + Clear-Host + Show-LxsBoxTop -Title 'HYPER-V' + Write-Host '' + Write-Host 'Enables the Hyper-V platform and management tools.' + Write-Host '' + Write-Host "$($script:Gray)Hyper-V is only on Pro, Enterprise and Education editions. Turning it$($script:NC)" + Write-Host "$($script:Gray)on takes over the hardware virtualization extensions, which can stop$($script:NC)" + Write-Host "$($script:Gray)VirtualBox and VMware from running their own VMs.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Enable Hyper-V?')) { Write-LxsInfo 'Cancelled.'; return } + if (-not (Test-LxsAdmin)) { + Write-LxsErr 'Enabling Hyper-V requires administrator rights.' + return + } + Write-Host '' + try { + Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -All -NoRestart -ErrorAction Stop | Out-Null + Write-LxsOk 'Hyper-V enabled — reboot to finish' + } catch { + Write-LxsErr "Could not enable Hyper-V: $($_.Exception.Message)" + } +} + +function Show-LxsContainersMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'CONTAINERS & VM' + Write-Host '' + Show-LxsMenuItem '1' 'Show virtualization status' + Show-LxsMenuItem '2' 'Install WSL 2' 'needs admin + reboot' + Show-LxsMenuItem '3' 'Enable Hyper-V' 'needs admin + reboot' + Show-LxsMenuItem '4' 'Install Docker / VirtualBox' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsVirtualizationStatus; Read-LxsEnter } + '2' { Install-LxsWsl; Read-LxsEnter } + '3' { Enable-LxsHyperV; Read-LxsEnter } + '4' { + Invoke-LxsPackageMenu -Title 'CONTAINERS' -Packages $LxsContainerPackages ` + -Note 'Docker Desktop needs WSL 2 or Hyper-V enabled first.' | Out-Null + Read-LxsEnter + } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-4.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsContainersMenu +exit 75 diff --git a/windows/apps/dev-pack.ps1 b/windows/apps/dev-pack.ps1 new file mode 100644 index 0000000..f806b59 --- /dev/null +++ b/windows/apps/dev-pack.ps1 @@ -0,0 +1,93 @@ +<# + LXS - Dev Pack (Windows) + Description: The usual development toolchain, installed through winget. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_dev_pack.log' + +if (-not (Assert-LxsWindows)) { exit 1 } + +$LxsDevPackages = @( + @{ Name = 'Git'; Id = 'Git.Git' } + @{ Name = 'VS Code'; Id = 'Microsoft.VisualStudioCode' } + @{ Name = 'Node.js LTS'; Id = 'OpenJS.NodeJS.LTS' } + @{ Name = 'Python 3'; Id = 'Python.Python.3.12' } + @{ Name = 'PowerShell 7'; Id = 'Microsoft.PowerShell' } + @{ Name = 'Windows Terminal'; Id = 'Microsoft.WindowsTerminal' } +) + +function Enable-LxsWsl { + Clear-Host + Show-LxsBoxTop -Title 'WSL 2' + Write-Host '' + Write-Host 'Installs the Windows Subsystem for Linux (WSL 2) and the default' + Write-Host 'Ubuntu distribution. Enables the Virtual Machine Platform feature,' + Write-Host "so a $($script:Bold)reboot is required$($script:NC) before WSL works." + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Install WSL 2?')) { + Write-LxsInfo 'Cancelled.' + return + } + if (-not (Test-LxsAdmin)) { + Write-LxsErr 'Installing WSL requires administrator rights.' + Write-Host "$($script:Gray) Re-run lxs from an elevated terminal.$($script:NC)" + return + } + Write-Host '' + & wsl.exe --install + if ($LASTEXITCODE -eq 0) { + Write-LxsOk 'WSL install started' + Write-LxsWarn 'Reboot to finish, then run: wsl --install -d Ubuntu' + } else { + Write-LxsWarn "wsl --install exited with code $LASTEXITCODE" + } +} + +function Show-LxsDevMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'DEV PACK' + Write-Host '' + Show-LxsMenuItem '1' 'Install dev tools' 'pick from the list' + Show-LxsMenuItem '2' 'Install WSL 2' 'needs admin + reboot' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { + Invoke-LxsPackageMenu -Title 'DEV PACK' -Packages $LxsDevPackages ` + -Note 'Everything here installs per-machine and lands on your PATH.' | Out-Null + Read-LxsEnter + } + '2' { Enable-LxsWsl; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-2.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsDevMenu +exit 75 diff --git a/windows/apps/index.ps1 b/windows/apps/index.ps1 new file mode 100644 index 0000000..5596227 --- /dev/null +++ b/windows/apps/index.ps1 @@ -0,0 +1,64 @@ +<# + LXS - Apps index (Windows) + Description: Interactive menu listing the installers in windows\apps + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +function Show-LxsAppsMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'APPLICATIONS' -Right 'APP_REPOSITORY' + Write-Host '' + Show-LxsMenuItem '01' 'Dev Pack' 'git, vscode, node, python' + Show-LxsMenuItem '02' 'Utilities' '7zip, powertoys, sysinternals' + Show-LxsMenuItem '03' 'Browsers & Media' 'librewolf, firefox, vlc, obs' + Show-LxsMenuItem '04' 'Containers & VM' 'wsl2, docker, hyper-v' + Show-LxsMenuItem '05' 'Claude Code' 'anthropic cli' + Show-LxsMenuItem '00' 'BACK' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + + $script = switch -Regex ($choice) { + '^0?1$' { 'apps\dev-pack.ps1' } + '^0?2$' { 'apps\utilities.ps1' } + '^0?3$' { 'apps\browsers-media.ps1' } + '^0?4$' { 'apps\containers.ps1' } + '^0?5$' { 'apps\claude-code.ps1' } + '^0?0$' { 'BACK' } + default { $null } + } + + if ($script -eq 'BACK') { return } + if (-not $script) { + Write-LxsErr 'Invalid protocol. Select 0-5.' + Start-Sleep -Seconds 1 + continue + } + + $code = Invoke-LxsSibling -RelativePath $script -SelfDirectory $PSScriptRoot + if ($code -ne 75) { Read-LxsEnter } + } +} + +Show-LxsAppsMenu +exit 75 diff --git a/windows/apps/utilities.ps1 b/windows/apps/utilities.ps1 new file mode 100644 index 0000000..f52820a --- /dev/null +++ b/windows/apps/utilities.ps1 @@ -0,0 +1,40 @@ +<# + LXS - Utilities (Windows) + Description: Everyday utilities installed through winget. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_utilities.log' + +if (-not (Assert-LxsWindows)) { exit 1 } + +$LxsUtilityPackages = @( + @{ Name = '7-Zip'; Id = '7zip.7zip' } + @{ Name = 'Notepad++'; Id = 'Notepad++.Notepad++' } + @{ Name = 'PowerToys'; Id = 'Microsoft.PowerToys' } + @{ Name = 'Sysinternals'; Id = 'Microsoft.Sysinternals' } + @{ Name = 'Everything'; Id = 'voidtools.Everything' } + @{ Name = 'ShareX'; Id = 'ShareX.ShareX' } +) + +Invoke-LxsPackageMenu -Title 'UTILITIES' -Packages $LxsUtilityPackages ` + -Note 'Search, screenshots, archives and the Sysinternals suite.' | Out-Null +exit 0 diff --git a/windows/lib/common.ps1 b/windows/lib/common.ps1 new file mode 100644 index 0000000..64ace7b --- /dev/null +++ b/windows/lib/common.ps1 @@ -0,0 +1,773 @@ +<# + LXS - Common library (Windows) + + Sourced by windows\lxs.ps1 and every sub-script. Mirrors linux/lib/common.sh: + colors, UI helpers, loggers, spinner, guards (admin, disk, winget) and shared + utilities (public IP, password generation, restore point, registry writes). + + Compatible with Windows PowerShell 5.1 and PowerShell 7+. + + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# ═══════════════════════════════════════════════════════════════════════════ +# Console setup — UTF-8 output (box drawing chars) and VT sequences (colors). +# Both are best-effort: a console that refuses either falls back to plain text. +# ═══════════════════════════════════════════════════════════════════════════ + +$script:LxsColorEnabled = $false +$script:LxsUnicodeEnabled = $false + +function Initialize-LxsConsole { + [CmdletBinding()] + param() + + try { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false + $script:LxsUnicodeEnabled = $true + } catch { + # Redirected output or a locked-down host — fall back to ASCII rules. + $script:LxsUnicodeEnabled = $false + } + + # PowerShell 7 renders VT natively; older hosts need ENABLE_VIRTUAL_TERMINAL_PROCESSING. + if ($PSVersionTable.PSVersion.Major -ge 6) { + $script:LxsColorEnabled = $true + } else { + try { + if (-not ('Lxs.NativeConsole' -as [type])) { + Add-Type -Namespace Lxs -Name NativeConsole -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError = true)] +public static extern IntPtr GetStdHandle(int nStdHandle); +[DllImport("kernel32.dll", SetLastError = true)] +public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); +[DllImport("kernel32.dll", SetLastError = true)] +public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); +'@ + } + $handle = [Lxs.NativeConsole]::GetStdHandle(-11) + $mode = 0 + if ([Lxs.NativeConsole]::GetConsoleMode($handle, [ref]$mode)) { + if ([Lxs.NativeConsole]::SetConsoleMode($handle, $mode -bor 0x0004)) { + $script:LxsColorEnabled = $true + } + } + } catch { + $script:LxsColorEnabled = $false + } + } + + if ($env:LXS_NO_COLOR) { $script:LxsColorEnabled = $false } + + $e = [char]0x1B + if ($script:LxsColorEnabled) { + $script:Red = "$e[38;2;255;64;64m" # errors, destructive actions + $script:Cyan = "$e[38;2;0;229;255m" # accents, titles, OK, info + $script:White = "$e[38;2;240;240;240m" # primary text + $script:Gray = "$e[38;2;140;140;140m" # secondary text, separators + $script:NC = "$e[0m" + $script:Bold = "$e[1m" + $script:Dim = "$e[2m" + } else { + $script:Red = ''; $script:Cyan = ''; $script:White = ''; $script:Gray = '' + $script:NC = ''; $script:Bold = ''; $script:Dim = '' + } +} + +Initialize-LxsConsole + +# ═══════════════════════════════════════════════════════════════════════════ +# UI helpers — title + horizontal rule, no closed box. Mirrors common.sh. +# +# Show-LxsBoxTop "TITLE" "RIGHT" → TITLE [ RIGHT ] +# ────────────────────────────────── +# Show-LxsBoxMid "SECTION" → ─ SECTION ──────────────────────── +# Show-LxsBoxBottom → ────────────────────────────────── +# Show-LxsMenuItem "01" "LABEL" "desc" → [01] LABEL // desc +# Show-LxsPrompt → > +# ═══════════════════════════════════════════════════════════════════════════ + +function Get-LxsTermWidth { + $cols = 80 + try { + $raw = $Host.UI.RawUI.WindowSize.Width + if ($raw -gt 0) { $cols = $raw } + } catch { + $cols = 80 + } + if ($cols -gt 100) { $cols = 100 } + if ($cols -lt 60) { $cols = 60 } + return $cols +} + +function Get-LxsRule { + param([int]$Width = 0) + if ($Width -le 0) { $Width = Get-LxsTermWidth } + $char = if ($script:LxsUnicodeEnabled) { [char]0x2500 } else { '-' } + return (New-Object string @($char, $Width)) +} + +function Show-LxsBoxTop { + param( + [Parameter(Mandatory = $true)][string]$Title, + [string]$Right = '' + ) + $cols = Get-LxsTermWidth + if ($Right) { + $padLen = $cols - 2 - $Title.Length - $Right.Length - 4 + if ($padLen -lt 1) { $padLen = 1 } + $pad = ' ' * $padLen + Write-Host " $($script:Cyan)$($script:Bold)$Title$($script:NC)$pad$($script:Gray)[ $($script:Cyan)$Right$($script:Gray) ]$($script:NC)" + } else { + Write-Host " $($script:Cyan)$($script:Bold)$Title$($script:NC)" + } + Write-Host "$($script:Gray)$(Get-LxsRule $cols)$($script:NC)" +} + +function Show-LxsBoxMid { + param([Parameter(Mandatory = $true)][string]$Title) + $cols = Get-LxsTermWidth + $fillLen = $cols - $Title.Length - 4 + if ($fillLen -lt 2) { $fillLen = 2 } + Write-Host "$($script:Gray)$(Get-LxsRule 1) $($script:Cyan)$($script:Bold)$Title$($script:NC) $($script:Gray)$(Get-LxsRule $fillLen)$($script:NC)" +} + +function Show-LxsBoxBottom { Write-Host "$($script:Gray)$(Get-LxsRule)$($script:NC)" } + +function Show-LxsSeparator { Write-Host "$($script:Gray)$(Get-LxsRule)$($script:NC)" } + +function Show-LxsTitle { + param( + [Parameter(Mandatory = $true)][string]$Title, + [string]$Subtitle = '' + ) + Write-Host '' + Show-LxsBoxTop -Title $Title -Right $Subtitle +} + +# Render a menu line. -Exit colors the key red (used for BACK / quit entries). +function Show-LxsMenuItem { + param( + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][string]$Label, + [string]$Description = '', + [switch]$Exit + ) + $keyColor = if ($Exit) { $script:Red } else { $script:Cyan } + if ($Description) { + $paddedLabel = $Label.PadRight(18) + Write-Host " $keyColor[$Key]$($script:NC) $($script:White)$($script:Bold)$paddedLabel$($script:NC) $($script:Gray)// $Description$($script:NC)" + } else { + Write-Host " $keyColor[$Key]$($script:NC) $($script:White)$($script:Bold)$Label$($script:NC)" + } +} + +function Show-LxsPrompt { Write-Host " $($script:Cyan)>$($script:NC) " -NoNewline } + +# Read a menu selection. Returns the trimmed string (never $null). +function Read-LxsChoice { + Show-LxsPrompt + $answer = Read-Host + if ($null -eq $answer) { return '' } + return $answer.Trim() +} + +function Read-LxsEnter { + param([string]$Message = 'Press Enter to continue...') + Write-Host '' + Read-Host -Prompt $Message | Out-Null +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Loggers +# ═══════════════════════════════════════════════════════════════════════════ + +function Write-LxsInfo { param([string]$Message) Write-Host "$($script:Cyan)[..]$($script:NC) $Message" } +function Write-LxsOk { param([string]$Message) Write-Host "$($script:Cyan)[OK]$($script:NC) $Message" } +function Write-LxsWarn { param([string]$Message) Write-Host "$($script:Cyan)[!!]$($script:NC) $Message" } +function Write-LxsErr { param([string]$Message) Write-Host "$($script:Red)[KO]$($script:NC) $Message" } + +# ═══════════════════════════════════════════════════════════════════════════ +# Option parsing +# +# Sub-scripts read $args by hand, exactly like the bash `for arg in "$@"` loop. +# A param([switch]) block cannot be used: LXS invokes sub-scripts with a +# splatted string[] (& $script @Arguments), and PowerShell binds every element +# of a splatted array positionally — '-Yes' would land in $args as a value and +# the switch would silently stay $false. +# +# $opts = Read-LxsFlags -Arguments $args -Known @('-Yes', '-y', '-Help') +# if ($opts.Unknown.Count -gt 0) { ... } +# $assumeYes = Test-LxsFlag $opts @('-Yes', '-y') +# ═══════════════════════════════════════════════════════════════════════════ + +function Read-LxsFlags { + param( + [string[]]$Arguments = @(), + [string[]]$Known = @() + ) + $present = @{} + $unknown = @() + foreach ($a in $Arguments) { + if ([string]::IsNullOrWhiteSpace($a)) { continue } + $match = $null + foreach ($k in $Known) { + if ($k -ieq $a) { $match = $k; break } + } + if ($match) { $present[$match.ToLower()] = $true } else { $unknown += $a } + } + return [pscustomobject]@{ Present = $present; Unknown = $unknown } +} + +function Test-LxsFlag { + param( + [Parameter(Mandatory = $true)]$Parsed, + [Parameter(Mandatory = $true)][string[]]$Name + ) + foreach ($n in $Name) { + if ($Parsed.Present.ContainsKey($n.ToLower())) { return $true } + } + return $false +} + +# Print the unknown options and return $true when there were any. +function Show-LxsUnknownFlags { + param([Parameter(Mandatory = $true)]$Parsed) + if ($Parsed.Unknown.Count -eq 0) { return $false } + foreach ($u in $Parsed.Unknown) { Write-LxsErr "Unknown option: $u" } + return $true +} + +# Yes/No confirmation. Default is No unless -DefaultYes is passed. +function Confirm-LxsAction { + param( + [Parameter(Mandatory = $true)][string]$Question, + [switch]$DefaultYes, + [switch]$AssumeYes + ) + if ($AssumeYes) { return $true } + $suffix = if ($DefaultYes) { '[Y/n]' } else { '[y/N]' } + $answer = Read-Host -Prompt "$Question $suffix" + if ([string]::IsNullOrWhiteSpace($answer)) { return [bool]$DefaultYes } + return $answer.Trim().ToLower() -in @('y', 'yes', 'o', 'oui') +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Progress helpers +# +# Invoke-LxsSpinner — external command, output captured to a log file, +# animated spinner (mirrors run_spinner in common.sh). +# Invoke-LxsStep — inline PowerShell work; prints [..] then [OK]/[KO]. +# +# Both return $true on success. Log file: $env:LXS_LOG_FILE or %TEMP%\lxs.log +# ═══════════════════════════════════════════════════════════════════════════ + +function Get-LxsTempDir { + if ($env:TEMP) { return $env:TEMP } + return [IO.Path]::GetTempPath() +} + +function Get-LxsLogFile { + if ($env:LXS_LOG_FILE) { return $env:LXS_LOG_FILE } + return (Join-Path (Get-LxsTempDir) 'lxs.log') +} + +function Invoke-LxsSpinner { + param( + [Parameter(Mandatory = $true)][string]$Message, + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$ArgumentList = @(), + [int[]]$SuccessCodes = @(0) + ) + $log = Get-LxsLogFile + $errLog = "$log.err" + $spinner = '|/-\'.ToCharArray() + $i = 0 + + Write-Host "$($script:Cyan)[..]$($script:NC) $Message" -NoNewline + + $startArgs = @{ + FilePath = $FilePath + NoNewWindow = $true + PassThru = $true + RedirectStandardOutput = $log + RedirectStandardError = $errLog + } + if ($ArgumentList.Count -gt 0) { $startArgs['ArgumentList'] = $ArgumentList } + + try { + $proc = Start-Process @startArgs + } catch { + Write-Host "`r$($script:Red)[KO]$($script:NC) $Message " + Write-LxsErr $_.Exception.Message + return $false + } + + while (-not $proc.HasExited) { + Write-Host "`r$($script:Cyan)[$($spinner[$i % 4]).]$($script:NC) $Message" -NoNewline + $i++ + Start-Sleep -Milliseconds 150 + } + $proc.WaitForExit() + + if (Test-Path $errLog) { + Get-Content $errLog -ErrorAction SilentlyContinue | Add-Content $log -ErrorAction SilentlyContinue + Remove-Item $errLog -Force -ErrorAction SilentlyContinue + } + + if ($SuccessCodes -contains $proc.ExitCode) { + Write-Host "`r$($script:Cyan)[OK]$($script:NC) $Message " + return $true + } + Write-Host "`r$($script:Red)[KO]$($script:NC) $Message (exit $($proc.ExitCode)) " + Write-Host "$($script:Gray) Log: $log$($script:NC)" + return $false +} + +function Invoke-LxsStep { + param( + [Parameter(Mandatory = $true)][string]$Message, + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock + ) + Write-Host "$($script:Cyan)[..]$($script:NC) $Message" -NoNewline + try { + & $ScriptBlock | Out-Null + Write-Host "`r$($script:Cyan)[OK]$($script:NC) $Message " + return $true + } catch { + Write-Host "`r$($script:Red)[KO]$($script:NC) $Message " + Write-Host "$($script:Gray) $($_.Exception.Message)$($script:NC)" + return $false + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Guards +# ═══════════════════════════════════════════════════════════════════════════ + +function Test-LxsAdmin { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal $identity + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + return $false + } +} + +# Re-launch the calling script elevated, preserving its arguments. Mirrors +# require_root in common.sh. Call near the top of a sub-script: +# Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args +# Returns only when already elevated; otherwise it starts a new elevated +# window and exits the current process. +function Assert-LxsAdmin { + param( + [string]$ScriptPath = $PSCommandPath, + [string[]]$Arguments = @() + ) + if (Test-LxsAdmin) { return } + + if (-not $ScriptPath -or -not (Test-Path $ScriptPath)) { + Write-LxsErr 'Administrator rights are required. Re-run this from an elevated terminal.' + exit 1 + } + + Write-LxsWarn 'Administrator rights required; opening an elevated window...' + $psExe = (Get-Process -Id $PID).Path + if (-not $psExe) { $psExe = 'powershell.exe' } + + # The elevated window is a *new* console: without a trailing pause it would + # close the moment the script ends and take all its output with it. + $quote = { param($v) "'" + ("$v" -replace "'", "''") + "'" } + $inner = "& $(& $quote $ScriptPath)" + foreach ($a in $Arguments) { $inner += " $(& $quote $a)" } + $inner += "; Write-Host ''; Read-Host 'Press Enter to close this window'" + + # Single pre-quoted string: Start-Process does not quote array elements, + # and $inner contains spaces. It uses only single quotes internally, so + # wrapping it in double quotes needs no further escaping. + $cmdLine = '-NoProfile -ExecutionPolicy Bypass -Command "' + $inner + '"' + try { + Start-Process -FilePath $psExe -ArgumentList $cmdLine -Verb RunAs | Out-Null + } catch { + Write-LxsErr 'Elevation was refused.' + exit 1 + } + Write-LxsInfo 'Continuing in the elevated window.' + exit 0 +} + +# Windows 10 1809 / Server 2019 (build 17763) is the documented floor. +function Assert-LxsWindows { + param([int]$MinimumBuild = 17763) + + # Not named $IsWindows: PowerShell 7 defines that as a read-only automatic + # variable, and assigning to it throws on every call. + $onWindows = $true + if ($PSVersionTable.PSVersion.Major -ge 6) { + $onWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows) + } + if (-not $onWindows) { + Write-LxsErr 'This script only runs on Windows. Use lxs.sh on Linux.' + return $false + } + + try { + $build = [int](Get-CimInstance Win32_OperatingSystem).BuildNumber + if ($build -lt $MinimumBuild) { + Write-LxsWarn "Windows build $build is older than the supported floor ($MinimumBuild). Some tools may fail." + } + } catch { + # Not fatal — CIM can be unavailable in stripped-down images. + } + return $true +} + +# Mirrors require_disk_space: fail early rather than half-install. +# Test-LxsDiskSpace -MinimumMB 1024 -Paths @('C:\') +function Test-LxsDiskSpace { + param( + [int]$MinimumMB = 500, + [string[]]$Paths = @($env:SystemDrive) + ) + $ok = $true + $seen = @{} + foreach ($path in $Paths) { + if ([string]::IsNullOrWhiteSpace($path)) { continue } + $root = try { [System.IO.Path]::GetPathRoot((Resolve-Path $path -ErrorAction Stop).Path) } catch { $null } + if (-not $root) { $root = [System.IO.Path]::GetPathRoot($path) } + if (-not $root) { + Write-LxsErr "Cannot read disk usage for $path" + $ok = $false + continue + } + if ($seen.ContainsKey($root)) { continue } + $seen[$root] = $true + + try { + $drive = New-Object System.IO.DriveInfo $root + $freeMB = [math]::Floor($drive.AvailableFreeSpace / 1MB) + } catch { + Write-LxsErr "Cannot read disk usage for $root" + $ok = $false + continue + } + if ($freeMB -lt $MinimumMB) { + Write-LxsErr "Not enough disk space on ${root}: ${freeMB}MB free, ${MinimumMB}MB required." + $ok = $false + } + } + return $ok +} + +# ═══════════════════════════════════════════════════════════════════════════ +# winget helpers +# ═══════════════════════════════════════════════════════════════════════════ + +function Test-LxsWinget { + return [bool](Get-Command winget -ErrorAction SilentlyContinue) +} + +function Assert-LxsWinget { + if (Test-LxsWinget) { return $true } + Write-LxsErr 'winget (App Installer) is not available on this machine.' + Write-Host "$($script:Gray) Install it from the Microsoft Store ('App Installer') or from$($script:NC)" + Write-Host "$($script:Gray) https://github.com/microsoft/winget-cli/releases, then re-run.$($script:NC)" + return $false +} + +# `winget list` is slow (a second or more per call), and a package menu asks +# about a dozen ids at once — so the full listing is fetched once and reused. +$script:LxsWingetListCache = $null + +function Get-LxsWingetListText { + param([switch]$Refresh) + if ($Refresh) { $script:LxsWingetListCache = $null } + if ($null -ne $script:LxsWingetListCache) { return $script:LxsWingetListCache } + try { + $script:LxsWingetListCache = (& winget list --accept-source-agreements 2>&1 | Out-String) + } catch { + $script:LxsWingetListCache = '' + } + return $script:LxsWingetListCache +} + +function Clear-LxsWingetListCache { $script:LxsWingetListCache = $null } + +function Test-LxsPackageInstalled { + param([Parameter(Mandatory = $true)][string]$Id) + $listing = Get-LxsWingetListText + if (-not $listing) { return $false } + return ($listing -match [regex]::Escape($Id)) +} + +# Install one winget package. Idempotent: an already-installed package is +# reported and skipped. Returns $true when the package ends up installed. +function Install-LxsWingetPackage { + param( + [Parameter(Mandatory = $true)][string]$Id, + [string]$Name = '', + [switch]$Force + ) + if (-not $Name) { $Name = $Id } + + if (-not $Force -and (Test-LxsPackageInstalled -Id $Id)) { + Write-LxsOk "$Name is already installed" + return $true + } + + # winget.exe is an App Execution Alias; resolve the real path so output + # redirection in Invoke-LxsSpinner works reliably. + $wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Source + if (-not $wingetPath) { $wingetPath = 'winget.exe' } + + # 0 = installed. -1978335135 (0x8A150061) = "no applicable update", which + # winget also returns when the package is already present at that version. + $wingetArgs = @( + 'install', '--id', $Id, '--exact', + '--silent', + '--accept-package-agreements', '--accept-source-agreements', + '--disable-interactivity' + ) + $installed = Invoke-LxsSpinner -Message "Installing $Name" -FilePath $wingetPath ` + -ArgumentList $wingetArgs -SuccessCodes @(0, -1978335135) + Clear-LxsWingetListCache + + if (-not $installed) { + Write-Host "$($script:Gray) Package id: $Id — check it with: winget search --id $Id$($script:NC)" + } + return $installed +} + +# Install a list of @{ Id = '...'; Name = '...' } entries, reporting a summary. +function Install-LxsWingetPackageSet { + param( + [Parameter(Mandatory = $true)][array]$Packages + ) + $failed = @() + foreach ($pkg in $Packages) { + if (-not (Install-LxsWingetPackage -Id $pkg.Id -Name $pkg.Name)) { + $failed += $pkg.Name + } + } + Write-Host '' + if ($failed.Count -eq 0) { + Write-LxsOk "All $($Packages.Count) package(s) are installed" + } else { + Write-LxsWarn "$($failed.Count) of $($Packages.Count) failed: $($failed -join ', ')" + } + return ($failed.Count -eq 0) +} + +# Interactive package picker shared by every apps\ script: lists the bundle, +# marks what is already installed, and installs "all" or the numbers picked. +# Returns $true when the user actually installed something. +function Invoke-LxsPackageMenu { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][array]$Packages, + [string]$Note = '' + ) + if (-not (Assert-LxsWinget)) { return $false } + + Clear-Host + Show-LxsBoxTop -Title $Title -Right 'WINGET' + Write-Host '' + if ($Note) { + Write-Host "$($script:Gray)$Note$($script:NC)" + Write-Host '' + } + + Write-LxsInfo 'Checking what is already installed...' + Get-LxsWingetListText -Refresh | Out-Null + Write-Host '' + + for ($i = 0; $i -lt $Packages.Count; $i++) { + $pkg = $Packages[$i] + $state = if (Test-LxsPackageInstalled -Id $pkg.Id) { "$($script:Cyan)[installed]$($script:NC)" } else { '' } + Write-Host (" $($script:Cyan)[{0,2}]$($script:NC) $($script:White)$($script:Bold){1,-24}$($script:NC) $($script:Gray){2,-34}$($script:NC) {3}" -f ` + ($i + 1), $pkg.Name, $pkg.Id, $state) + } + + Write-Host '' + Write-Host "$($script:Gray)Enter the numbers to install, separated by commas (e.g. 1,3,5),$($script:NC)" + Write-Host "$($script:Gray)or 'all' for everything above. Empty cancels.$($script:NC)" + Write-Host '' + $selection = Read-Host -Prompt 'Selection' + + if ([string]::IsNullOrWhiteSpace($selection)) { + Write-LxsInfo 'Cancelled.' + return $false + } + + $targets = @() + if ($selection.Trim() -ieq 'all') { + $targets = $Packages + } else { + foreach ($part in ($selection -split ',')) { + $part = $part.Trim() + if ($part -notmatch '^\d+$') { Write-LxsErr "Not a number: $part"; return $false } + $idx = [int]$part - 1 + if ($idx -lt 0 -or $idx -ge $Packages.Count) { Write-LxsErr "Out of range: $part"; return $false } + $targets += $Packages[$idx] + } + } + + Write-Host '' + Show-LxsSeparator + Write-Host '' + Install-LxsWingetPackageSet -Packages $targets | Out-Null + return $true +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Misc utilities +# ═══════════════════════════════════════════════════════════════════════════ + +function Get-LxsPublicIP { + foreach ($url in @('https://ifconfig.me/ip', 'https://icanhazip.com', 'https://ipinfo.io/ip')) { + try { + $ip = (Invoke-RestMethod -Uri $url -TimeoutSec 5 -ErrorAction Stop) + if ($ip) { return ("$ip").Trim() } + } catch { + continue + } + } + try { + $local = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | + Select-Object -First 1 -ExpandProperty IPAddress + if ($local) { return $local } + } catch { + # no network + } + return 'unknown' +} + +function New-LxsPassword { + param([int]$Length = 32) + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + $bytes = New-Object byte[] $Length + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + $sb = New-Object System.Text.StringBuilder + foreach ($b in $bytes) { [void]$sb.Append($chars[$b % $chars.Length]) } + return $sb.ToString() +} + +# Create a System Restore point, enabling System Protection first if needed. +# Used as a safety net before debloat/hardening. Requires admin. +function New-LxsRestorePoint { + param([string]$Description = 'LXS checkpoint') + + if (-not (Test-LxsAdmin)) { + Write-LxsErr 'A restore point requires administrator rights.' + return $false + } + + $drive = "$env:SystemDrive\" + try { + Enable-ComputerRestore -Drive $drive -ErrorAction Stop + } catch { + Write-LxsWarn "Could not enable System Protection on ${drive}: $($_.Exception.Message)" + } + + # Windows rate-limits restore points to one per 24h unless this is relaxed. + try { + New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' ` + -Name 'SystemRestorePointCreationFrequency' -Value 0 -PropertyType DWord -Force -ErrorAction Stop | Out-Null + } catch { + # Non-fatal: the checkpoint may be skipped if one was made recently. + } + + try { + Checkpoint-Computer -Description $Description -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop + Write-LxsOk "Restore point created: $Description" + return $true + } catch { + Write-LxsErr "Restore point failed: $($_.Exception.Message)" + return $false + } +} + +# Write a registry value, creating the key path when it does not exist. +function Set-LxsRegistryValue { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)]$Value, + [ValidateSet('String', 'ExpandString', 'Binary', 'DWord', 'MultiString', 'QWord')] + [string]$Type = 'DWord' + ) + try { + if (-not (Test-Path $Path)) { New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } + New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $Type -Force -ErrorAction Stop | Out-Null + return $true + } catch { + Write-LxsErr "Registry write failed ($Path\$Name): $($_.Exception.Message)" + return $false + } +} + +# Append a directory to the *user* PATH (no admin needed). Idempotent. +function Add-LxsUserPath { + param([Parameter(Mandatory = $true)][string]$Directory) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($null -eq $current) { $current = '' } + # Windows paths are case-insensitive and a trailing backslash is + # meaningless, so compare normalized — otherwise every run appends a + # near-duplicate entry. + $normalized = $Directory.TrimEnd('\') + foreach ($entry in ($current -split ';')) { + if ($entry -and ($entry.Trim().TrimEnd('\') -ieq $normalized)) { return $false } + } + + $updated = if ($current.TrimEnd(';')) { "$($current.TrimEnd(';'));$Directory" } else { $Directory } + [Environment]::SetEnvironmentVariable('Path', $updated, 'User') + $env:Path = "$env:Path;$Directory" + return $true +} + +# Run a sibling script from the same directory. Prefers the local file +# (installed layout or repo checkout), falls back to the remote raw URL. +# Mirrors run_sibling() in linux/tools/index.sh. +function Invoke-LxsSibling { + param( + [Parameter(Mandatory = $true)][string]$RelativePath, # e.g. tools\system-info.ps1 + [string[]]$Arguments = @(), + [string]$SelfDirectory = '' + ) + $name = Split-Path $RelativePath -Leaf + if (-not $SelfDirectory) { $SelfDirectory = $PSScriptRoot } + + $local = Join-Path $SelfDirectory $name + if (Test-Path $local) { + $global:LASTEXITCODE = 0 + & $local @Arguments + return $LASTEXITCODE + } + + $base = $env:LXS_RAW_PLATFORM_BASE + if (-not $base) { $base = 'https://git.hyko.cx/hykocx/lxs/raw/branch/main/windows' } + $url = "$base/$($RelativePath -replace '\\', '/')" + $temp = Join-Path (Get-LxsTempDir) ("lxs.{0}.{1}.ps1" -f [IO.Path]::GetFileNameWithoutExtension($name), [guid]::NewGuid().ToString('N').Substring(0, 8)) + + Write-LxsInfo "Fetching $($script:Bold)$name$($script:NC)..." + try { + Invoke-WebRequest -Uri $url -OutFile $temp -UseBasicParsing -Headers @{ 'Cache-Control' = 'no-cache' } -ErrorAction Stop + } catch { + Write-LxsErr "Failed to download $RelativePath" + Write-Host "$($script:Gray) URL: $url$($script:NC)" + return 1 + } + Write-LxsOk 'Payload acquired' + try { + $global:LASTEXITCODE = 0 + & $temp @Arguments + return $LASTEXITCODE + } finally { + Remove-Item $temp -Force -ErrorAction SilentlyContinue + } +} diff --git a/windows/lxs.ps1 b/windows/lxs.ps1 new file mode 100644 index 0000000..6ce4613 --- /dev/null +++ b/windows/lxs.ps1 @@ -0,0 +1,502 @@ +<# + LXS - Windows multi-tool + Description: Centralized workstation management and deployment toolkit + Repo: https://git.hyko.cx/hykocx/lxs + License: MIT + + Mirrors linux/lxs.sh: same verbs, same menu, same update flow. + Compatible with Windows PowerShell 5.1 and PowerShell 7+. +#> + +# Raw $args pass-through: sub-scripts get their own flags verbatim +# (e.g. `lxs tool harden -Yes`) without this script trying to bind them. +$LxsArgs = @($args) +$LxsCommand = if ($LxsArgs.Count -gt 0) { [string]$LxsArgs[0] } else { '' } +$LxsRest = if ($LxsArgs.Count -gt 1) { $LxsArgs[1..($LxsArgs.Count - 1)] } else { @() } + +# ═══════════════════════════════════════════════════════════════════════════ +# Configuration +# ═══════════════════════════════════════════════════════════════════════════ + +$LxsScriptDir = $PSScriptRoot +if (-not $LxsScriptDir) { $LxsScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path } + +$LxsVersion = 'dev' +if ($LxsScriptDir) { + $versionFile = Join-Path $LxsScriptDir '..\VERSION' + if (Test-Path $versionFile) { + $raw = (Get-Content $versionFile -TotalCount 1 -ErrorAction SilentlyContinue) + if ($raw) { $LxsVersion = $raw.Trim() } + } +} + +$LxsRepoUrl = 'https://git.hyko.cx/hykocx/lxs' +$LxsBranch = if ($env:LXS_BRANCH) { $env:LXS_BRANCH } else { 'main' } +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { "$LxsRepoUrl/raw/branch/$LxsBranch" } + +# Platform sub-tree: every apps\ and tools\ path resolves under it. +$LxsPlatformDir = 'windows' +$env:LXS_RAW_BASE = $LxsRawBase +$env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/$LxsPlatformDir" + +$LxsZipUrl = if ($env:LXS_ZIP_URL) { $env:LXS_ZIP_URL } else { "$LxsRepoUrl/archive/$LxsBranch.zip" } +# LOCALAPPDATA is normally set; fall back so a service/SYSTEM context that +# lacks it still resolves to a writable directory instead of erroring out. +$LxsLocalAppData = $env:LOCALAPPDATA +if (-not $LxsLocalAppData) { + $LxsLocalAppData = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE 'AppData\Local' } else { [IO.Path]::GetTempPath() } +} +$LxsInstallDir = if ($env:LXS_INSTALL_DIR) { $env:LXS_INSTALL_DIR } else { Join-Path $LxsLocalAppData 'lxs' } +$LxsBinDir = Join-Path $LxsInstallDir 'bin' +$LxsShimPath = Join-Path $LxsBinDir 'lxs.cmd' +$LxsCacheDir = Join-Path $LxsInstallDir 'cache' +$LxsVersionCache = Join-Path $LxsCacheDir 'remote_version' +$LxsVersionTtl = 86400 + +$script:LxsUpdateAvailable = $false +$script:LxsRemoteVersion = '' +$script:LxsPublicIpCache = $null + +# ═══════════════════════════════════════════════════════════════════════════ +# Load common library +# Prefers a sibling lib\common.ps1 (installed or repo checkout); falls back to +# fetching it from the remote when run via `irm ... | iex`. +# ═══════════════════════════════════════════════════════════════════════════ + +$libPath = if ($LxsScriptDir) { Join-Path $LxsScriptDir 'lib\common.ps1' } else { $null } +if ($libPath -and (Test-Path $libPath)) { + . $libPath +} else { + try { + $libSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($libSource)) +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Core helpers +# ═══════════════════════════════════════════════════════════════════════════ + +function Get-LxsCachedPublicIP { + if ($null -eq $script:LxsPublicIpCache) { + $script:LxsPublicIpCache = Get-LxsPublicIP + } + return $script:LxsPublicIpCache +} + +# Compare two dotted versions. Returns $true when $Candidate is newer. +function Test-LxsNewerVersion { + param([string]$Current, [string]$Candidate) + try { + return ([version]$Candidate -gt [version]$Current) + } catch { + return ($Candidate -ne $Current) + } +} + +function Test-LxsRemoteVersion { + $script:LxsUpdateAvailable = $false + $script:LxsRemoteVersion = '' + + try { + if (-not (Test-Path $LxsCacheDir)) { New-Item -ItemType Directory -Path $LxsCacheDir -Force | Out-Null } + } catch { + return + } + + $age = [double]::MaxValue + if (Test-Path $LxsVersionCache) { + $age = ((Get-Date) - (Get-Item $LxsVersionCache).LastWriteTime).TotalSeconds + } + + if ($age -ge $LxsVersionTtl) { + # Refresh inline but on a short leash — a slow repo must not stall the menu. + try { + $remote = Invoke-RestMethod -Uri "$LxsRawBase/VERSION" -TimeoutSec 3 ` + -Headers @{ 'Cache-Control' = 'no-cache' } -UseBasicParsing -ErrorAction Stop + ("$remote").Trim() | Set-Content -Path $LxsVersionCache -Encoding ASCII + } catch { + # Offline or unreachable: fall through to whatever is cached. + } + } + + if (-not (Test-Path $LxsVersionCache)) { return } + $cached = (Get-Content $LxsVersionCache -TotalCount 1 -ErrorAction SilentlyContinue) + if (-not $cached) { return } + $cached = $cached.Trim() + + if (Test-LxsNewerVersion -Current $LxsVersion -Candidate $cached) { + $script:LxsUpdateAvailable = $true + $script:LxsRemoteVersion = $cached + } +} + +function Show-LxsLogo { + Show-LxsBoxTop -Title "LXS // v$LxsVersion" -Right 'WINDOWS' +} + +function Show-LxsHeader { + Clear-Host + Show-LxsLogo + + if ($script:LxsUpdateAvailable) { + Write-Host '' + Write-Host " $($script:Cyan)[!!] UPDATE_AVAILABLE$($script:NC) $($script:White)v$($script:LxsRemoteVersion)$($script:NC) $($script:Gray)// run ``lxs update``$($script:NC)" + } + + $hostname = $env:COMPUTERNAME + $osName = 'Windows' + $build = '' + $uptime = 'unknown' + $totalMem = 0.0 + $usedMem = 0.0 + try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $osName = ($os.Caption -replace '^Microsoft ', '').Trim() + $build = "$($os.Version) ($($os.BuildNumber))" + $span = (Get-Date) - $os.LastBootUpTime + $uptime = "{0}d {1}h {2}m" -f $span.Days, $span.Hours, $span.Minutes + $totalMem = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1) + $usedMem = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1MB, 1) + } catch { + # CIM unavailable — the header degrades but the menu still works. + } + + $cores = $env:NUMBER_OF_PROCESSORS + $ip = Get-LxsCachedPublicIP + + $disk = 'unknown' + try { + $sys = New-Object System.IO.DriveInfo "$env:SystemDrive\" + $totalGB = [math]::Round($sys.TotalSize / 1GB, 1) + $usedGB = [math]::Round(($sys.TotalSize - $sys.AvailableFreeSpace) / 1GB, 1) + $pct = if ($sys.TotalSize -gt 0) { [math]::Round(100 * ($sys.TotalSize - $sys.AvailableFreeSpace) / $sys.TotalSize) } else { 0 } + $disk = "${usedGB}G/${totalGB}G (${pct}%)" + } catch { + # No access to the system drive info. + } + + Write-Host '' + Write-Host (" $($script:Cyan)NODE$($script:NC) $($script:White){0,-26}$($script:NC) $($script:Cyan)BUILD$($script:NC) $($script:White){1}$($script:NC)" -f $hostname, $build) + Write-Host (" $($script:Cyan)ADDR$($script:NC) $($script:White){0,-26}$($script:NC) $($script:Cyan)UP$($script:NC) $($script:White){1}$($script:NC)" -f $ip, $uptime) + Write-Host (" $($script:Cyan)OS$($script:NC) $($script:White){0,-26}$($script:NC) $($script:Cyan)CPU$($script:NC) $($script:White){1} cores$($script:NC)" -f $osName, $cores) + Write-Host (" $($script:Cyan)DISK$($script:NC) $($script:White){0,-26}$($script:NC) $($script:Cyan)MEM$($script:NC) $($script:White){1} / {2} GB$($script:NC)" -f $disk, $usedMem, $totalMem) + Write-Host '' +} + +# Run a sub-script (tools\... or apps\...). Prefers the installed copy; +# falls back to fetching it. Mirrors download_and_run() in linux/lxs.sh. +function Invoke-LxsScript { + param( + [Parameter(Mandatory = $true)][string]$RelativePath, + [string[]]$Arguments = @() + ) + $name = Split-Path $RelativePath -Leaf + $localPath = $null + if ($LxsScriptDir) { $localPath = Join-Path $LxsScriptDir $RelativePath } + + $target = $null + $temp = $null + if ($localPath -and (Test-Path $localPath)) { + $target = $localPath + } else { + $url = "$env:LXS_RAW_PLATFORM_BASE/$($RelativePath -replace '\\', '/')" + $temp = Join-Path (Get-LxsTempDir) ("lxs.{0}.{1}.ps1" -f [IO.Path]::GetFileNameWithoutExtension($name), [guid]::NewGuid().ToString('N').Substring(0, 8)) + Write-Host '' + Write-LxsInfo "Fetching $($script:Bold)$name$($script:NC)..." + try { + Invoke-WebRequest -Uri $url -OutFile $temp -UseBasicParsing ` + -Headers @{ 'Cache-Control' = 'no-cache' } -ErrorAction Stop + } catch { + Write-LxsErr "Failed to download $RelativePath" + Write-Host "$($script:Gray) URL: $url$($script:NC)" + return 1 + } + Write-LxsOk 'Payload acquired' + $target = $temp + } + + Write-Host '' + Show-LxsSeparator + Write-Host '' + + $global:LASTEXITCODE = 0 + try { + & $target @Arguments + $code = $LASTEXITCODE + } finally { + if ($temp) { Remove-Item $temp -Force -ErrorAction SilentlyContinue } + } + if ($null -eq $code) { $code = 0 } + + Write-Host '' + Show-LxsSeparator + if ($code -eq 0 -or $code -eq 75) { + Write-LxsOk 'Script completed successfully' + } else { + Write-LxsWarn "Script exited with code: $code" + } + return $code +} + +# ═══════════════════════════════════════════════════════════════════════════ +# CLI commands +# ═══════════════════════════════════════════════════════════════════════════ + +function Invoke-LxsCmdVersion { Write-Host "lxs $LxsVersion" } + +function Invoke-LxsCmdHelp { + Write-Host @" +LXS - Windows multi-tool (v$LxsVersion) + +Usage: + lxs Interactive menu (browse apps and tools) + lxs setup Install lxs to $LxsInstallDir and add it to PATH + lxs update Update all installed files to latest + lxs install Install an application bundle + lxs tool [args] Run a system tool + lxs info Show system info + lxs version Show version + lxs help Show this help + +Applications: + dev Git, VS Code, Node.js, Python, PowerShell 7, Windows Terminal + utilities 7-Zip, Notepad++, PowerToys, Sysinternals, Everything, ShareX + browsers Firefox, LibreWolf, Chrome, Brave, VLC, OBS Studio + containers WSL2, Hyper-V, Docker Desktop, VirtualBox + claude-code Claude Code CLI (+ Git for Windows) + +Tools: + system System information and diagnostics + network Network diagnostics and repair + cleanup Free disk space (temp, update cache, recycle bin, WinSxS) + repair SFC / DISM / chkdsk integrity repair + restore-point Create, list or roll back to a System Restore point + update Windows Update + winget upgrade + harden Baseline security hardening + remote-access Remote Desktop and OpenSSH server + debloat Remove preinstalled apps and disable telemetry + +Source: $LxsRepoUrl +"@ +} + +function Invoke-LxsCmdInstall { + param([string]$App, [string[]]$Arguments = @()) + switch ($App) { + 'dev' { return (Invoke-LxsScript 'apps\dev-pack.ps1' $Arguments) } + 'utilities' { return (Invoke-LxsScript 'apps\utilities.ps1' $Arguments) } + 'browsers' { return (Invoke-LxsScript 'apps\browsers-media.ps1' $Arguments) } + 'containers' { return (Invoke-LxsScript 'apps\containers.ps1' $Arguments) } + 'claude-code' { return (Invoke-LxsScript 'apps\claude-code.ps1' $Arguments) } + '' { Write-LxsErr 'Missing app name. Try: lxs help'; return 1 } + default { Write-LxsErr "Unknown app: $App. Try: lxs help"; return 1 } + } +} + +function Invoke-LxsCmdTool { + param([string]$Tool, [string[]]$Arguments = @()) + switch ($Tool) { + 'system' { return (Invoke-LxsScript 'tools\system-info.ps1' $Arguments) } + 'network' { return (Invoke-LxsScript 'tools\net-diag.ps1' $Arguments) } + 'cleanup' { return (Invoke-LxsScript 'tools\cleanup.ps1' $Arguments) } + 'repair' { return (Invoke-LxsScript 'tools\repair.ps1' $Arguments) } + 'restore-point' { return (Invoke-LxsScript 'tools\restore-point.ps1' $Arguments) } + 'update' { return (Invoke-LxsScript 'tools\update-windows.ps1' $Arguments) } + 'harden' { return (Invoke-LxsScript 'tools\harden.ps1' $Arguments) } + 'remote-access' { return (Invoke-LxsScript 'tools\remote-access.ps1' $Arguments) } + 'debloat' { return (Invoke-LxsScript 'tools\debloat.ps1' $Arguments) } + '' { Write-LxsErr 'Missing tool name. Try: lxs help'; return 1 } + default { Write-LxsErr "Unknown tool: $Tool. Try: lxs help"; return 1 } + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# setup / update — download the repo zip and mirror it into $LxsInstallDir. +# Everything lands under %LOCALAPPDATA%, so no elevation is needed here; +# individual tools elevate themselves when they touch the machine. +# ═══════════════════════════════════════════════════════════════════════════ + +function Invoke-LxsSync { + param([ValidateSet('setup', 'update')][string]$Action = 'update') + + $work = Join-Path (Get-LxsTempDir) ("lxs.install.{0}" -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + try { + New-Item -ItemType Directory -Path $work -Force | Out-Null + } catch { + Write-LxsErr 'Failed to create temp dir' + return 1 + } + + try { + $zip = Join-Path $work 'lxs.zip' + Write-LxsInfo "Fetching $LxsZipUrl..." + try { + $progressBackup = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $LxsZipUrl -OutFile $zip -UseBasicParsing ` + -Headers @{ 'Cache-Control' = 'no-cache' } -ErrorAction Stop + $ProgressPreference = $progressBackup + } catch { + Write-LxsErr "Download failed: $($_.Exception.Message)" + return 1 + } + + $extracted = Join-Path $work 'extracted' + try { + Expand-Archive -Path $zip -DestinationPath $extracted -Force -ErrorAction Stop + } catch { + Write-LxsErr "Extraction failed: $($_.Exception.Message)" + return 1 + } + + # Gitea wraps the tree in a single / directory: strip it. + $root = $extracted + $entries = @(Get-ChildItem -Path $extracted -Force) + if ($entries.Count -eq 1 -and $entries[0].PSIsContainer) { $root = $entries[0].FullName } + + if (-not (Test-Path (Join-Path $root "$LxsPlatformDir\lxs.ps1")) -or + -not (Test-Path (Join-Path $root 'VERSION'))) { + Write-LxsErr "Archive is missing $LxsPlatformDir\lxs.ps1 or VERSION" + return 1 + } + + # Replace the payload directories wholesale (so files deleted upstream + # really disappear) but keep bin\ and cache\, so the shim on PATH and + # the version cache survive an update. The old tree is moved aside + # rather than deleted, and put back if the copy fails. + if (-not (Test-Path $LxsInstallDir)) { New-Item -ItemType Directory -Path $LxsInstallDir -Force | Out-Null } + $backup = Join-Path $work 'previous' + New-Item -ItemType Directory -Path $backup -Force | Out-Null + foreach ($dir in @('linux', 'windows')) { + $dest = Join-Path $LxsInstallDir $dir + if (Test-Path $dest) { Move-Item -Path $dest -Destination (Join-Path $backup $dir) -Force -ErrorAction SilentlyContinue } + } + try { + Copy-Item -Path (Join-Path $root '*') -Destination $LxsInstallDir -Recurse -Force -ErrorAction Stop + } catch { + Write-LxsErr "Copy failed: $($_.Exception.Message)" + Write-LxsInfo 'Restoring the previous install...' + foreach ($dir in @('linux', 'windows')) { + $saved = Join-Path $backup $dir + if (Test-Path $saved) { + $dest = Join-Path $LxsInstallDir $dir + if (Test-Path $dest) { Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue } + Move-Item -Path $saved -Destination $dest -Force -ErrorAction SilentlyContinue + } + } + return 1 + } + + # Command shim on PATH. -ExecutionPolicy Bypass so a restricted policy + # (the default on desktop SKUs) does not block the installed scripts. + if (-not (Test-Path $LxsBinDir)) { New-Item -ItemType Directory -Path $LxsBinDir -Force | Out-Null } + $shim = @" +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\$LxsPlatformDir\lxs.ps1" %* +exit /b %ERRORLEVEL% +"@ + Set-Content -Path $LxsShimPath -Value $shim -Encoding ASCII -Force + + try { + if (Add-LxsUserPath -Directory $LxsBinDir) { + Write-LxsOk "Added $LxsBinDir to your user PATH (open a new terminal to use ``lxs``)" + } + } catch { + # A blocked registry write must not fail an otherwise good install. + Write-LxsWarn "Could not update your user PATH: $($_.Exception.Message)" + Write-Host "$($script:Gray) Run lxs directly: $LxsShimPath$($script:NC)" + } + + Remove-Item $LxsVersionCache -Force -ErrorAction SilentlyContinue + + $newVersion = (Get-Content (Join-Path $LxsInstallDir 'VERSION') -TotalCount 1 -ErrorAction SilentlyContinue) + if ($newVersion) { $newVersion = $newVersion.Trim() } + Write-LxsOk "lxs $newVersion installed in $LxsInstallDir" + Write-LxsOk "Command: $LxsShimPath" + return 0 + } finally { + Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Interactive menu +# ═══════════════════════════════════════════════════════════════════════════ + +function Invoke-LxsMainMenu { + if (-not (Assert-LxsWindows)) { exit 1 } + Test-LxsRemoteVersion + + while ($true) { + Show-LxsHeader + Show-LxsBoxMid 'SELECT' + Write-Host '' + Show-LxsMenuItem '01' 'APPLICATIONS' 'deploy stacks' + Show-LxsMenuItem '02' 'TOOLS' 'windows toolbox' + Show-LxsMenuItem 'UU' 'FETCH_UPDATE' 'sync remote' + Show-LxsMenuItem '00' 'JACK_OUT' 'exit shell' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + + switch -Regex ($choice) { + '^0?1$' { Invoke-LxsScript 'apps\index.ps1' | Out-Null; continue } + '^0?2$' { Invoke-LxsScript 'tools\index.ps1' | Out-Null; continue } + '^[uU]{1,2}$' { + if ((Invoke-LxsSync -Action update) -eq 0) { + $reinstalled = Join-Path $LxsInstallDir "$LxsPlatformDir\lxs.ps1" + if (Test-Path $reinstalled) { + Read-LxsEnter 'Press Enter to reload with the new version...' + & $reinstalled + exit 0 + } + } + Read-LxsEnter + continue + } + '^0?0$' { + Clear-Host + Show-LxsLogo + Write-Host "$($script:Cyan)JACK_OUT$($script:NC) $($script:Gray)// session terminated$($script:NC)" + Write-Host '' + exit 0 + } + default { + Write-LxsErr 'Invalid protocol.' + Start-Sleep -Seconds 1 + } + } + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Entrypoint +# ═══════════════════════════════════════════════════════════════════════════ + +$exitCode = 0 +switch ($LxsCommand) { + 'install' { $exitCode = Invoke-LxsCmdInstall -App ([string]($LxsRest | Select-Object -First 1)) -Arguments @($LxsRest | Select-Object -Skip 1) } + 'tool' { $exitCode = Invoke-LxsCmdTool -Tool ([string]($LxsRest | Select-Object -First 1)) -Arguments @($LxsRest | Select-Object -Skip 1) } + 'info' { Show-LxsHeader } + 'update' { $exitCode = Invoke-LxsSync -Action update } + 'setup' { $exitCode = Invoke-LxsSync -Action setup } + 'install-self' { $exitCode = Invoke-LxsSync -Action setup } + 'version' { Invoke-LxsCmdVersion } + '-v' { Invoke-LxsCmdVersion } + '--version' { Invoke-LxsCmdVersion } + 'help' { Invoke-LxsCmdHelp } + '-h' { Invoke-LxsCmdHelp } + '--help' { Invoke-LxsCmdHelp } + '' { Invoke-LxsMainMenu } + default { + Write-LxsErr "Unknown command: $LxsCommand" + Invoke-LxsCmdHelp + $exitCode = 1 + } +} +exit $exitCode diff --git a/windows/tools/cleanup.ps1 b/windows/tools/cleanup.ps1 new file mode 100644 index 0000000..85c8f34 --- /dev/null +++ b/windows/tools/cleanup.ps1 @@ -0,0 +1,227 @@ +<# + LXS - Disk Cleanup (Windows) + Description: Reclaim disk space (temp files, Windows Update cache, recycle + bin, Prefetch, error reports, WinSxS component store). + Repo: https://git.hyko.cx/hykocx/lxs + + Usage: cleanup.ps1 [-Yes] [-NoUpdateCache] [-NoRecycleBin] [-NoPrefetch] + [-NoComponentCleanup] [-Help] +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_cleanup.log' + +$LxsOpts = Read-LxsFlags -Arguments $args -Known @( + '-Yes', '-y', '-NoUpdateCache', '-NoRecycleBin', '-NoPrefetch', + '-NoComponentCleanup', '-Help', '-h' +) +if (Show-LxsUnknownFlags $LxsOpts) { exit 1 } +$Yes = Test-LxsFlag $LxsOpts @('-Yes', '-y') +$NoUpdateCache = Test-LxsFlag $LxsOpts @('-NoUpdateCache') +$NoRecycleBin = Test-LxsFlag $LxsOpts @('-NoRecycleBin') +$NoPrefetch = Test-LxsFlag $LxsOpts @('-NoPrefetch') +$NoComponentCleanup = Test-LxsFlag $LxsOpts @('-NoComponentCleanup') +$Help = Test-LxsFlag $LxsOpts @('-Help', '-h') + +if ($Help) { + Write-Host @' +Usage: cleanup.ps1 [options] + +Options: + -Yes Skip the confirmation prompt + -NoUpdateCache Skip the Windows Update download cache + -NoRecycleBin Skip emptying the recycle bin + -NoPrefetch Skip the Prefetch folder + -NoComponentCleanup Skip DISM /StartComponentCleanup (the slow one) + -Help Show this help +'@ + exit 0 +} + +function Get-LxsFreeBytes { + try { return (New-Object System.IO.DriveInfo "$env:SystemDrive\").AvailableFreeSpace } catch { return 0 } +} + +function Format-LxsSize { + param([double]$Bytes) + if ($Bytes -ge 1GB) { return ('{0:N2} GB' -f ($Bytes / 1GB)) } + if ($Bytes -ge 1MB) { return ('{0:N1} MB' -f ($Bytes / 1MB)) } + return ('{0:N0} KB' -f ($Bytes / 1KB)) +} + +function Get-LxsFolderSize { + param([string]$Path) + if (-not (Test-Path $Path)) { return 0 } + try { + $sum = (Get-ChildItem -Path $Path -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + if ($null -eq $sum) { return 0 } + return $sum + } catch { + return 0 + } +} + +# Delete the *contents* of a folder, never the folder itself. Files locked by a +# running process are skipped silently — that is expected on a live system. +function Clear-LxsFolderContents { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + if (-not (Test-Path $Path)) { + Write-Host "$($script:Gray) $Label — not present, skipped$($script:NC)" + return 0 + } + $before = Get-LxsFolderSize -Path $Path + $skipped = 0 + Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue | ForEach-Object { + try { + Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction Stop + } catch { + $skipped++ + } + } + $after = Get-LxsFolderSize -Path $Path + $freed = [math]::Max(0, $before - $after) + $suffix = if ($skipped -gt 0) { " ($skipped item(s) in use, skipped)" } else { '' } + Write-LxsOk "$Label — $(Format-LxsSize $freed) reclaimed$suffix" + return $freed +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Plan +# ═══════════════════════════════════════════════════════════════════════════ + +if (-not (Assert-LxsWindows)) { exit 1 } + +$isAdmin = Test-LxsAdmin +$doUpdateCache = (-not $NoUpdateCache) -and $isAdmin +$doPrefetch = (-not $NoPrefetch) -and $isAdmin +$doComponent = (-not $NoComponentCleanup) -and $isAdmin + +Clear-Host +Show-LxsBoxTop -Title 'DISK CLEANUP' +Write-Host '' +Write-Host 'The following locations will be cleaned:' +Write-Host ' - User temp folder' +if ($isAdmin) { Write-Host ' - System temp folder (C:\Windows\Temp)' } +if (-not $NoRecycleBin) { Write-Host ' - Recycle bin (all drives)' } +if ($doUpdateCache) { Write-Host ' - Windows Update download cache' } +if ($doPrefetch) { Write-Host ' - Prefetch folder' } +if ($isAdmin) { Write-Host ' - Windows Error Reporting queue and Delivery Optimization cache' } +if ($doComponent) { Write-Host ' - WinSxS component store (DISM /StartComponentCleanup) - slow' } +Write-Host '' + +if (-not $isAdmin) { + Write-LxsWarn 'Not running as administrator: system-wide locations are skipped.' + Write-Host "$($script:Gray) Re-run from an elevated terminal to clean them.$($script:NC)" + Write-Host '' +} + +Show-LxsSeparator +Write-Host '' +if (-not (Confirm-LxsAction -Question 'Proceed with the cleanup?' -DefaultYes -AssumeYes:$Yes)) { + Write-LxsInfo 'Cancelled.' + exit 0 +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Run +# ═══════════════════════════════════════════════════════════════════════════ + +$freeBefore = Get-LxsFreeBytes +Write-Host '' +Write-LxsInfo "Free space before: $(Format-LxsSize $freeBefore)" +Write-Host '' + +Clear-LxsFolderContents -Path (Get-LxsTempDir) -Label 'User temp' | Out-Null + +if ($isAdmin) { + Clear-LxsFolderContents -Path (Join-Path $env:SystemRoot 'Temp') -Label 'System temp' | Out-Null + Clear-LxsFolderContents -Path (Join-Path $env:ProgramData 'Microsoft\Windows\WER\ReportQueue') -Label 'Error reports' | Out-Null + Clear-LxsFolderContents -Path (Join-Path $env:SystemRoot 'SoftwareDistribution\DeliveryOptimization') -Label 'Delivery Optimization' | Out-Null +} + +if ($doPrefetch) { + Clear-LxsFolderContents -Path (Join-Path $env:SystemRoot 'Prefetch') -Label 'Prefetch' | Out-Null +} + +if (-not $NoRecycleBin) { + try { + Clear-RecycleBin -Force -ErrorAction Stop + Write-LxsOk 'Recycle bin emptied' + } catch { + # Clear-RecycleBin throws when the bin is already empty. + Write-Host "$($script:Gray) Recycle bin — already empty or not accessible$($script:NC)" + } +} + +if ($doUpdateCache) { + # The download cache can only be cleared with the update services stopped; + # they are restarted afterwards whether or not the deletion succeeded. + $services = @('wuauserv', 'bits') + $stopped = @() + foreach ($svc in $services) { + try { + $s = Get-Service -Name $svc -ErrorAction Stop + if ($s.Status -eq 'Running') { + Stop-Service -Name $svc -Force -ErrorAction Stop + $stopped += $svc + } + } catch { + Write-LxsWarn "Could not stop $svc — the update cache may be partially locked." + } + } + try { + Clear-LxsFolderContents -Path (Join-Path $env:SystemRoot 'SoftwareDistribution\Download') -Label 'Windows Update cache' | Out-Null + } finally { + foreach ($svc in $stopped) { + try { Start-Service -Name $svc -ErrorAction Stop } catch { Write-LxsWarn "Failed to restart $svc — start it manually." } + } + } +} + +if ($doComponent) { + Write-Host '' + Write-LxsInfo 'Running DISM component cleanup (several minutes, no output until done)...' + $ok = Invoke-LxsSpinner -Message 'DISM /StartComponentCleanup' -FilePath 'dism.exe' ` + -ArgumentList @('/Online', '/Cleanup-Image', '/StartComponentCleanup') + if (-not $ok) { Write-LxsWarn 'Component cleanup did not complete; see the log above.' } +} + +# Windows.old is reported, never auto-deleted: removing it forfeits the +# ability to roll back a feature update. +$windowsOld = Join-Path $env:SystemDrive 'Windows.old' +if (Test-Path $windowsOld) { + Write-Host '' + Write-LxsWarn "$windowsOld exists ($(Format-LxsSize (Get-LxsFolderSize -Path $windowsOld)))." + Write-Host "$($script:Gray) It holds your previous Windows install. Removing it blocks rollback;$($script:NC)" + Write-Host "$($script:Gray) use Settings > System > Storage > Temporary files if you want it gone.$($script:NC)" +} + +$freeAfter = Get-LxsFreeBytes +Write-Host '' +Show-LxsSeparator +Write-Host '' +Write-LxsOk "Free space after: $(Format-LxsSize $freeAfter)" +Write-LxsOk "Reclaimed: $(Format-LxsSize ([math]::Max(0, $freeAfter - $freeBefore)))" +Write-Host '' +exit 0 diff --git a/windows/tools/debloat.ps1 b/windows/tools/debloat.ps1 new file mode 100644 index 0000000..b0b4649 --- /dev/null +++ b/windows/tools/debloat.ps1 @@ -0,0 +1,483 @@ +<# + LXS - Debloat and privacy (Windows) + Description: Remove preinstalled apps you pick, and turn off telemetry, + Cortana, ads, tracking tasks, Copilot and Recall. + + Safety rules this script follows: + - a restore point is created before the first change of a session + - no app is removed unless you explicitly select it + - the registry/service/task tweaks can be undone from the menu (option 8) + - apps you remove come back only by reinstalling them from the Store + + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_debloat.log' + +if (-not (Assert-LxsWindows)) { exit 1 } +Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args + +$script:LxsCheckpointDone = $false + +# One restore point per session, created lazily before the first real change. +function Assert-LxsCheckpoint { + if ($script:LxsCheckpointDone) { return $true } + Write-Host '' + Write-LxsInfo 'Creating a restore point before changing anything...' + $ok = New-LxsRestorePoint -Description 'LXS before debloat' + if (-not $ok) { + Write-Host '' + Write-LxsWarn 'The restore point could not be created.' + if (-not (Confirm-LxsAction -Question 'Continue anyway, without a rollback point?')) { + return $false + } + } + $script:LxsCheckpointDone = $true + return $true +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Preinstalled apps +# +# Only packages on this list are ever offered. Anything not listed here — +# including the Store, App Installer (winget), Terminal, Defender UI, the +# VCLibs/.NET runtime frameworks and every driver package — is left alone, +# because removing those breaks Windows or LXS itself. +# ═══════════════════════════════════════════════════════════════════════════ + +$LxsRemovableApps = @( + @{ Pattern = 'Microsoft.3DBuilder'; Label = '3D Builder' } + @{ Pattern = 'Microsoft.549981C3F5F10'; Label = 'Cortana' } + @{ Pattern = 'Microsoft.BingFinance'; Label = 'Bing Finance' } + @{ Pattern = 'Microsoft.BingNews'; Label = 'Bing News' } + @{ Pattern = 'Microsoft.BingSports'; Label = 'Bing Sports' } + @{ Pattern = 'Microsoft.BingWeather'; Label = 'Weather' } + @{ Pattern = 'Microsoft.BingSearch'; Label = 'Web Search (Bing)' } + @{ Pattern = 'Microsoft.GetHelp'; Label = 'Get Help' } + @{ Pattern = 'Microsoft.Getstarted'; Label = 'Tips / Get Started' } + @{ Pattern = 'Microsoft.Messaging'; Label = 'Messaging' } + @{ Pattern = 'Microsoft.Microsoft3DViewer'; Label = '3D Viewer' } + @{ Pattern = 'Microsoft.MicrosoftJournal'; Label = 'Journal' } + @{ Pattern = 'Microsoft.MicrosoftOfficeHub'; Label = 'Office Hub' } + @{ Pattern = 'Microsoft.MicrosoftSolitaireCollection'; Label = 'Solitaire Collection' } + @{ Pattern = 'Microsoft.MixedReality.Portal'; Label = 'Mixed Reality Portal' } + @{ Pattern = 'Microsoft.NetworkSpeedTest'; Label = 'Network Speed Test' } + @{ Pattern = 'Microsoft.News'; Label = 'News' } + @{ Pattern = 'Microsoft.Office.OneNote'; Label = 'OneNote (Store version)' } + @{ Pattern = 'Microsoft.Office.Sway'; Label = 'Sway' } + @{ Pattern = 'Microsoft.OneConnect'; Label = 'Mobile Plans' } + @{ Pattern = 'Microsoft.People'; Label = 'People' } + @{ Pattern = 'Microsoft.PowerAutomateDesktop'; Label = 'Power Automate Desktop' } + @{ Pattern = 'Microsoft.Print3D'; Label = 'Print 3D' } + @{ Pattern = 'Microsoft.SkypeApp'; Label = 'Skype' } + @{ Pattern = 'Microsoft.Todos'; Label = 'Microsoft To Do' } + @{ Pattern = 'Microsoft.Wallet'; Label = 'Wallet' } + @{ Pattern = 'Microsoft.WindowsAlarms'; Label = 'Alarms & Clock' } + @{ Pattern = 'Microsoft.WindowsFeedbackHub'; Label = 'Feedback Hub' } + @{ Pattern = 'Microsoft.WindowsMaps'; Label = 'Maps' } + @{ Pattern = 'Microsoft.WindowsSoundRecorder'; Label = 'Sound Recorder' } + @{ Pattern = 'Microsoft.Xbox.TCUI'; Label = 'Xbox TCUI' } + @{ Pattern = 'Microsoft.XboxApp'; Label = 'Xbox Console Companion' } + @{ Pattern = 'Microsoft.XboxGameOverlay'; Label = 'Xbox Game Overlay' } + @{ Pattern = 'Microsoft.XboxGamingOverlay'; Label = 'Xbox Game Bar' } + @{ Pattern = 'Microsoft.XboxIdentityProvider'; Label = 'Xbox Identity Provider' } + @{ Pattern = 'Microsoft.XboxSpeechToTextOverlay'; Label = 'Xbox Speech To Text' } + @{ Pattern = 'Microsoft.YourPhone'; Label = 'Phone Link' } + @{ Pattern = 'Microsoft.ZuneMusic'; Label = 'Media Player / Groove Music' } + @{ Pattern = 'Microsoft.ZuneVideo'; Label = 'Movies & TV' } + @{ Pattern = 'MicrosoftTeams'; Label = 'Teams (personal / chat)' } + @{ Pattern = 'MSTeams'; Label = 'Teams (new client)' } + @{ Pattern = 'Clipchamp.Clipchamp'; Label = 'Clipchamp' } + @{ Pattern = 'Microsoft.Copilot'; Label = 'Copilot app' } + @{ Pattern = 'Microsoft.WindowsFamilySafety'; Label = 'Family Safety' } + @{ Pattern = 'SpotifyAB.SpotifyMusic'; Label = 'Spotify (OEM stub)' } + @{ Pattern = 'Disney.37853FC22B2CE'; Label = 'Disney+ (OEM stub)' } +) + +function Get-LxsInstalledBloat { + $found = @() + foreach ($entry in $LxsRemovableApps) { + try { + $pkgs = @(Get-AppxPackage -Name $entry.Pattern -ErrorAction SilentlyContinue) + } catch { + $pkgs = @() + } + if ($pkgs.Count -gt 0) { + $found += [pscustomobject]@{ + Label = $entry.Label + Pattern = $entry.Pattern + Name = $pkgs[0].Name + Version = $pkgs[0].Version + } + } + } + return $found +} + +function Remove-LxsSelectedApps { + Clear-Host + Show-LxsBoxTop -Title 'REMOVE PREINSTALLED APPS' + Write-Host '' + Write-LxsInfo 'Scanning installed packages...' + $found = @(Get-LxsInstalledBloat) + Write-Host '' + + if ($found.Count -eq 0) { + Write-LxsOk 'None of the known preinstalled apps are present.' + return + } + + for ($i = 0; $i -lt $found.Count; $i++) { + Write-Host (" $($script:Cyan)[{0,2}]$($script:NC) $($script:White){1,-32}$($script:NC) $($script:Gray){2}$($script:NC)" -f ` + ($i + 1), $found[$i].Label, $found[$i].Name) + } + + Write-Host '' + Write-Host "$($script:Gray)Enter the numbers to remove, separated by commas (e.g. 1,4,7),$($script:NC)" + Write-Host "$($script:Gray)or 'all' for every entry above. Empty cancels — nothing is removed$($script:NC)" + Write-Host "$($script:Gray)unless you name it here.$($script:NC)" + Write-Host '' + $selection = Read-Host -Prompt 'Selection' + + if ([string]::IsNullOrWhiteSpace($selection)) { + Write-LxsInfo 'Cancelled — nothing removed.' + return + } + + $targets = @() + if ($selection.Trim() -ieq 'all') { + $targets = $found + } else { + foreach ($part in ($selection -split ',')) { + $part = $part.Trim() + if ($part -notmatch '^\d+$') { + Write-LxsErr "Not a number: $part" + return + } + $idx = [int]$part - 1 + if ($idx -lt 0 -or $idx -ge $found.Count) { + Write-LxsErr "Out of range: $part" + return + } + $targets += $found[$idx] + } + } + $targets = @($targets | Sort-Object Name -Unique) + + Write-Host '' + Write-Host 'These will be removed for the current user:' + foreach ($t in $targets) { Write-Host " - $($t.Label)" } + Write-Host '' + Write-Host "$($script:Gray)They can be reinstalled later from the Microsoft Store.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question "Remove $($targets.Count) app(s)?")) { + Write-LxsInfo 'Cancelled.' + return + } + if (-not (Assert-LxsCheckpoint)) { return } + + $alsoProvisioned = Confirm-LxsAction -Question 'Also remove them from the image, so new user accounts do not get them?' + + Write-Host '' + foreach ($t in $targets) { + try { + Get-AppxPackage -Name $t.Pattern -ErrorAction Stop | Remove-AppxPackage -ErrorAction Stop + Write-LxsOk "$($t.Label) removed" + } catch { + Write-LxsWarn "$($t.Label): $($_.Exception.Message)" + } + + if ($alsoProvisioned) { + try { + $prov = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | + Where-Object { $_.DisplayName -like $t.Pattern }) + foreach ($p in $prov) { + Remove-AppxProvisionedPackage -Online -PackageName $p.PackageName -ErrorAction Stop | Out-Null + Write-Host "$($script:Gray) also removed from the image$($script:NC)" + } + } catch { + Write-LxsWarn "$($t.Label): could not remove the provisioned package." + } + } + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Privacy tweaks +# +# Every entry carries the value to set and the value to restore, so option 8 +# can undo exactly what option 2-6 did. Default = $null means "delete the +# value", which is what returns a policy key to its Windows default. +# ═══════════════════════════════════════════════════════════════════════════ + +$LxsTweaks = @( + # --- telemetry --- + @{ Group = 'telemetry'; Label = 'Diagnostic data set to the minimum'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'; Name = 'AllowTelemetry'; Value = 0; Default = $null } + @{ Group = 'telemetry'; Label = 'Do not send device names with telemetry'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'; Name = 'AllowDeviceNameInTelemetry'; Value = 0; Default = $null } + @{ Group = 'telemetry'; Label = 'Advertising ID disabled'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\AdvertisingInfo'; Name = 'Enabled'; Value = 0; Default = 1 } + @{ Group = 'telemetry'; Label = 'App launch tracking disabled'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced'; Name = 'Start_TrackProgs'; Value = 0; Default = 1 } + @{ Group = 'telemetry'; Label = 'Feedback requests disabled'; + Path = 'HKCU:\SOFTWARE\Microsoft\Siuf\Rules'; Name = 'NumberOfSIUFInPeriod'; Value = 0; Default = $null } + + # --- cortana / search --- + @{ Group = 'cortana'; Label = 'Cortana disabled'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'; Name = 'AllowCortana'; Value = 0; Default = $null } + @{ Group = 'cortana'; Label = 'Web results in Start disabled'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'; Name = 'DisableWebSearch'; Value = 1; Default = $null } + @{ Group = 'cortana'; Label = 'Connected web search disabled'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Windows Search'; Name = 'ConnectedSearchUseWeb'; Value = 0; Default = $null } + @{ Group = 'cortana'; Label = 'Search highlights disabled'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings'; Name = 'IsDynamicSearchBoxEnabled'; Value = 0; Default = 1 } + + # --- ads and suggestions --- + @{ Group = 'ads'; Label = 'Start menu app suggestions off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'SystemPaneSuggestionsEnabled'; Value = 0; Default = 1 } + @{ Group = 'ads'; Label = 'Silent install of promoted apps off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'SilentInstalledAppsEnabled'; Value = 0; Default = 1 } + @{ Group = 'ads'; Label = 'Lock screen spotlight ads off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'RotatingLockScreenOverlayEnabled'; Value = 0; Default = 1 } + @{ Group = 'ads'; Label = 'Windows tips and tricks off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'SubscribedContent-338389Enabled'; Value = 0; Default = 1 } + @{ Group = 'ads'; Label = 'Suggested content in Settings off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'SubscribedContent-338393Enabled'; Value = 0; Default = 1 } + @{ Group = 'ads'; Label = 'Welcome experience after updates off'; + Path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; Name = 'SubscribedContent-310093Enabled'; Value = 0; Default = 1 } + + # --- copilot / recall --- + @{ Group = 'ai'; Label = 'Windows Copilot turned off'; + Path = 'HKCU:\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot'; Name = 'TurnOffWindowsCopilot'; Value = 1; Default = $null } + @{ Group = 'ai'; Label = 'Windows Copilot turned off (machine-wide)'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot'; Name = 'TurnOffWindowsCopilot'; Value = 1; Default = $null } + @{ Group = 'ai'; Label = 'Recall snapshots disabled'; + Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsAI'; Name = 'DisableAIDataAnalysis'; Value = 1; Default = $null } + @{ Group = 'ai'; Label = 'Recall snapshots disabled (per user)'; + Path = 'HKCU:\SOFTWARE\Policies\Microsoft\Windows\WindowsAI'; Name = 'DisableAIDataAnalysis'; Value = 1; Default = $null } +) + +$LxsTrackingTasks = @( + '\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraisal', + '\Microsoft\Windows\Application Experience\ProgramDataUpdater', + '\Microsoft\Windows\Application Experience\StartupAppTask', + '\Microsoft\Windows\Customer Experience Improvement Program\Consolidator', + '\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip', + '\Microsoft\Windows\Autochk\Proxy', + '\Microsoft\Windows\Feedback\Siuf\DmClient', + '\Microsoft\Windows\Feedback\Siuf\DmClientOnScenarioDownload' +) + +$LxsTrackingServices = @( + @{ Name = 'DiagTrack'; Label = 'Connected User Experiences and Telemetry'; Default = 'Automatic' }, + @{ Name = 'dmwappushservice'; Label = 'WAP Push Message Routing'; Default = 'Manual' } +) + +function Invoke-LxsTweakGroup { + param( + [Parameter(Mandatory = $true)][string]$Group, + [Parameter(Mandatory = $true)][string]$Title + ) + $entries = @($LxsTweaks | Where-Object { $_.Group -eq $Group }) + + Clear-Host + Show-LxsBoxTop -Title $Title + Write-Host '' + Write-Host 'The following settings will be changed:' + foreach ($e in $entries) { Write-Host " - $($e.Label)" } + if ($Group -eq 'telemetry') { + Write-Host ' - Telemetry services disabled (DiagTrack, dmwappushservice)' + Write-Host ' - Tracking scheduled tasks disabled' + } + Write-Host '' + Write-Host "$($script:Gray)All of this is reversible from menu option 8.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Apply?')) { + Write-LxsInfo 'Cancelled.' + return + } + if (-not (Assert-LxsCheckpoint)) { return } + + Write-Host '' + foreach ($e in $entries) { + if (Set-LxsRegistryValue -Path $e.Path -Name $e.Name -Value $e.Value) { + Write-LxsOk $e.Label + } + } + + if ($Group -ne 'telemetry') { return } + + Write-Host '' + foreach ($svc in $LxsTrackingServices) { + try { + $s = Get-Service -Name $svc.Name -ErrorAction Stop + if ($s.Status -eq 'Running') { Stop-Service -Name $svc.Name -Force -ErrorAction SilentlyContinue } + Set-Service -Name $svc.Name -StartupType Disabled -ErrorAction Stop + Write-LxsOk "$($svc.Label) service disabled" + } catch { + Write-LxsWarn "$($svc.Label): $($_.Exception.Message)" + } + } + + Write-Host '' + $disabled = 0 + foreach ($taskPath in $LxsTrackingTasks) { + $leaf = Split-Path $taskPath -Leaf + $parent = (Split-Path $taskPath -Parent) + '\' + try { + Disable-ScheduledTask -TaskName $leaf -TaskPath $parent -ErrorAction Stop | Out-Null + $disabled++ + } catch { + # Absent on this edition/build — nothing to disable. + } + } + Write-LxsOk "$disabled tracking scheduled task(s) disabled" +} + +function Restore-LxsTweaks { + Clear-Host + Show-LxsBoxTop -Title 'REVERT PRIVACY TWEAKS' + Write-Host '' + Write-Host 'This restores every registry value, service and scheduled task that' + Write-Host 'this script changes, back to the Windows default.' + Write-Host '' + Write-Host "$($script:Gray)Apps you removed are NOT restored — reinstall them from the Store.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Revert the privacy tweaks?')) { + Write-LxsInfo 'Cancelled.' + return + } + + Write-Host '' + foreach ($e in $LxsTweaks) { + if ($null -eq $e.Default) { + try { + if (Test-Path $e.Path) { + Remove-ItemProperty -Path $e.Path -Name $e.Name -ErrorAction Stop + } + Write-LxsOk "$($e.Label) — policy removed" + } catch { + # Already absent: that is the default state we wanted. + } + } else { + if (Set-LxsRegistryValue -Path $e.Path -Name $e.Name -Value $e.Default) { + Write-LxsOk "$($e.Label) — restored to default" + } + } + } + + Write-Host '' + foreach ($svc in $LxsTrackingServices) { + try { + Set-Service -Name $svc.Name -StartupType $svc.Default -ErrorAction Stop + Write-LxsOk "$($svc.Label) service restored to $($svc.Default)" + } catch { + Write-LxsWarn "$($svc.Label): $($_.Exception.Message)" + } + } + + Write-Host '' + $enabled = 0 + foreach ($taskPath in $LxsTrackingTasks) { + $leaf = Split-Path $taskPath -Leaf + $parent = (Split-Path $taskPath -Parent) + '\' + try { + Enable-ScheduledTask -TaskName $leaf -TaskPath $parent -ErrorAction Stop | Out-Null + $enabled++ + } catch { + # Not present on this build. + } + } + Write-LxsOk "$enabled scheduled task(s) re-enabled" + Write-Host '' + Write-LxsWarn 'Sign out and back in for the per-user settings to take effect.' +} + +function Show-LxsDebloatStatus { + Clear-Host + Show-LxsBoxTop -Title 'CURRENT STATE' + Write-Host '' + $installed = @(Get-LxsInstalledBloat) + Write-Host " Known preinstalled apps still present : $($installed.Count) / $($LxsRemovableApps.Count)" + + $applied = 0 + foreach ($e in $LxsTweaks) { + try { + $current = (Get-ItemProperty -Path $e.Path -Name $e.Name -ErrorAction Stop).($e.Name) + if ($current -eq $e.Value) { $applied++ } + } catch { + # Value absent -> tweak not applied. + } + } + Write-Host " Privacy tweaks applied : $applied / $($LxsTweaks.Count)" + + foreach ($svc in $LxsTrackingServices) { + try { + $s = Get-Service -Name $svc.Name -ErrorAction Stop + Write-Host " $($svc.Name.PadRight(38)): $($s.Status) / $($s.StartType)" + } catch { + Write-Host " $($svc.Name.PadRight(38)): not present" + } + } + Write-Host '' +} + +function Show-LxsDebloatMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'DEBLOAT & PRIVACY' -Right 'ADMIN' + Write-Host '' + Show-LxsMenuItem '1' 'Show current state' + Show-LxsMenuItem '2' 'Remove preinstalled apps' 'you pick each one' + Show-LxsMenuItem '3' 'Disable telemetry' 'policy, services, tasks' + Show-LxsMenuItem '4' 'Disable Cortana & web search' + Show-LxsMenuItem '5' 'Disable ads & suggestions' + Show-LxsMenuItem '6' 'Disable Copilot & Recall' + Show-LxsMenuItem '7' 'Create a restore point' 'before doing anything' + Show-LxsMenuItem '8' 'Revert privacy tweaks' 'undo 3-6' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsDebloatStatus; Read-LxsEnter } + '2' { Remove-LxsSelectedApps; Read-LxsEnter } + '3' { Invoke-LxsTweakGroup -Group 'telemetry' -Title 'DISABLE TELEMETRY'; Read-LxsEnter } + '4' { Invoke-LxsTweakGroup -Group 'cortana' -Title 'DISABLE CORTANA & WEB SEARCH'; Read-LxsEnter } + '5' { Invoke-LxsTweakGroup -Group 'ads' -Title 'DISABLE ADS & SUGGESTIONS'; Read-LxsEnter } + '6' { Invoke-LxsTweakGroup -Group 'ai' -Title 'DISABLE COPILOT & RECALL'; Read-LxsEnter } + '7' { + if (New-LxsRestorePoint -Description 'LXS manual checkpoint') { $script:LxsCheckpointDone = $true } + Read-LxsEnter + } + '8' { Restore-LxsTweaks; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-8.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsDebloatMenu +exit 75 diff --git a/windows/tools/harden.ps1 b/windows/tools/harden.ps1 new file mode 100644 index 0000000..c23d1e2 --- /dev/null +++ b/windows/tools/harden.ps1 @@ -0,0 +1,327 @@ +<# + LXS - Harden Windows + Description: Apply a baseline security posture (firewall, SMBv1, UAC, + Defender, LLMNR/NetBIOS) and audit local accounts. + Mirror of linux/tools/harden.sh. + Repo: https://git.hyko.cx/hykocx/lxs + + Usage: harden.ps1 [-Yes] [-NoFirewall] [-NoSmb] [-NoUac] [-NoDefender] + [-NoNameResolution] [-NoRestorePoint] [-Help] +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_harden.log' + +$LxsOpts = Read-LxsFlags -Arguments $args -Known @( + '-Yes', '-y', '-NoFirewall', '-NoSmb', '-NoUac', '-NoDefender', + '-NoNameResolution', '-NoRestorePoint', '-Help', '-h' +) +if (Show-LxsUnknownFlags $LxsOpts) { exit 1 } +$AssumeYes = Test-LxsFlag $LxsOpts @('-Yes', '-y') +$DoFirewall = -not (Test-LxsFlag $LxsOpts @('-NoFirewall')) +$DoSmb = -not (Test-LxsFlag $LxsOpts @('-NoSmb')) +$DoUac = -not (Test-LxsFlag $LxsOpts @('-NoUac')) +$DoDefender = -not (Test-LxsFlag $LxsOpts @('-NoDefender')) +$DoNameResolution = -not (Test-LxsFlag $LxsOpts @('-NoNameResolution')) +$DoRestorePoint = -not (Test-LxsFlag $LxsOpts @('-NoRestorePoint')) + +if (Test-LxsFlag $LxsOpts @('-Help', '-h')) { + Write-Host @' +Usage: harden.ps1 [options] + +Options: + -Yes, -y Skip the confirmation prompt + -NoFirewall Skip the Windows Firewall configuration + -NoSmb Skip disabling SMBv1 + -NoUac Skip raising the UAC prompt level + -NoDefender Skip the Microsoft Defender settings + -NoNameResolution Skip disabling LLMNR and NetBIOS-over-TCP/IP + -NoRestorePoint Do not create a restore point first + -Help, -h Show this help +'@ + exit 0 +} + +if (-not (Assert-LxsWindows)) { exit 1 } +Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args + +# ═══════════════════════════════════════════════════════════════════════════ +# Plan +# ═══════════════════════════════════════════════════════════════════════════ + +Clear-Host +Show-LxsBoxTop -Title 'HARDEN WINDOWS' -Right 'ADMIN' +Write-Host '' +Write-Host 'The following changes will be applied to this machine:' +if ($DoFirewall) { + Write-Host ' - Firewall: enabled on Domain/Private/Public, inbound blocked by default' +} +if ($DoSmb) { + Write-Host ' - SMBv1: client and server disabled (legacy, exploited by WannaCry/EternalBlue)' +} +if ($DoUac) { + Write-Host ' - UAC: prompt for consent on the secure desktop, never silently elevate' +} +if ($DoDefender) { + Write-Host ' - Defender: real-time protection, PUA blocking, cloud protection, network protection' +} +if ($DoNameResolution) { + Write-Host ' - LLMNR and NetBIOS-over-TCP/IP disabled (blocks classic LAN credential relay)' +} +Write-Host ' - Audit: local administrators, blank passwords, Guest account (report only)' +Write-Host '' +if ($DoNameResolution) { + Write-Host "$($script:Gray)Note: disabling LLMNR/NetBIOS can break flat-name resolution on small$($script:NC)" + Write-Host "$($script:Gray)LANs without a DNS server. Pass -NoNameResolution to keep them.$($script:NC)" + Write-Host '' +} +Show-LxsSeparator +Write-Host '' + +if (-not (Confirm-LxsAction -Question 'Apply this hardening baseline?' -AssumeYes:$AssumeYes)) { + Write-LxsInfo 'Cancelled.' + exit 0 +} + +if ($DoRestorePoint) { + Write-Host '' + Write-LxsInfo 'Creating a restore point first...' + New-LxsRestorePoint -Description 'LXS before harden' | Out-Null +} + +$changes = 0 +$problems = 0 + +# ═══════════════════════════════════════════════════════════════════════════ +# Firewall +# ═══════════════════════════════════════════════════════════════════════════ + +if ($DoFirewall) { + Write-Host '' + Show-LxsBoxMid 'FIREWALL' + Write-Host '' + try { + Set-NetFirewallProfile -Profile Domain, Private, Public -Enabled True ` + -DefaultInboundAction Block -DefaultOutboundAction Allow -ErrorAction Stop + Write-LxsOk 'Firewall enabled on all profiles, inbound blocked by default' + $changes++ + } catch { + Write-LxsErr "Firewall configuration failed: $($_.Exception.Message)" + $problems++ + } + + try { + Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { + Write-Host " $($_.Name.PadRight(8)) enabled=$($_.Enabled) inbound=$($_.DefaultInboundAction) outbound=$($_.DefaultOutboundAction)" + } + } catch { + # Reporting only. + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# SMBv1 +# ═══════════════════════════════════════════════════════════════════════════ + +if ($DoSmb) { + Write-Host '' + Show-LxsBoxMid 'SMBv1' + Write-Host '' + try { + Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction Stop + Write-LxsOk 'SMBv1 server protocol disabled' + $changes++ + } catch { + Write-LxsWarn "Could not disable the SMBv1 server: $($_.Exception.Message)" + $problems++ + } + + try { + $feature = Get-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -ErrorAction Stop + if ($feature.State -eq 'Enabled') { + Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction Stop | Out-Null + Write-LxsOk 'SMBv1 client feature disabled (reboot to complete)' + $changes++ + } else { + Write-LxsOk 'SMBv1 client feature was already disabled' + } + } catch { + Write-LxsWarn "Could not query or disable the SMB1Protocol feature: $($_.Exception.Message)" + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# UAC +# ═══════════════════════════════════════════════════════════════════════════ + +if ($DoUac) { + Write-Host '' + Show-LxsBoxMid 'UAC' + Write-Host '' + $uacPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + $uacOk = $true + # EnableLUA=1 keeps UAC on, ConsentPromptBehaviorAdmin=2 always prompts on + # the secure desktop, PromptOnSecureDesktop=1 makes that desktop mandatory. + $uacOk = (Set-LxsRegistryValue -Path $uacPath -Name 'EnableLUA' -Value 1) -and $uacOk + $uacOk = (Set-LxsRegistryValue -Path $uacPath -Name 'ConsentPromptBehaviorAdmin' -Value 2) -and $uacOk + $uacOk = (Set-LxsRegistryValue -Path $uacPath -Name 'PromptOnSecureDesktop' -Value 1) -and $uacOk + if ($uacOk) { + Write-LxsOk 'UAC set to prompt for consent on the secure desktop' + $changes++ + } else { + $problems++ + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Defender +# ═══════════════════════════════════════════════════════════════════════════ + +if ($DoDefender) { + Write-Host '' + Show-LxsBoxMid 'DEFENDER' + Write-Host '' + if (-not (Get-Command Set-MpPreference -ErrorAction SilentlyContinue)) { + Write-LxsWarn 'Microsoft Defender cmdlets are not available (third-party AV installed?). Skipped.' + } else { + $settings = @( + @{ Name = 'Real-time protection'; Args = @{ DisableRealtimeMonitoring = $false } }, + @{ Name = 'PUA blocking'; Args = @{ PUAProtection = 1 } }, + @{ Name = 'Cloud protection'; Args = @{ MAPSReporting = 2 } }, + @{ Name = 'Sample submission'; Args = @{ SubmitSamplesConsent = 1 } }, + @{ Name = 'Network protection'; Args = @{ EnableNetworkProtection = 1 } }, + @{ Name = 'Script scanning'; Args = @{ DisableScriptScanning = $false } }, + @{ Name = 'Archive scanning'; Args = @{ DisableArchiveScanning = $false } } + ) + foreach ($s in $settings) { + try { + $mpArgs = $s.Args + Set-MpPreference @mpArgs -ErrorAction Stop + Write-LxsOk "$($s.Name) enabled" + $changes++ + } catch { + # Tamper Protection blocks these writes by design; that is a + # stronger guarantee than what we were trying to set. + Write-LxsWarn "$($s.Name): $($_.Exception.Message)" + } + } + try { + $status = Get-MpComputerStatus -ErrorAction Stop + Write-Host '' + Write-Host " Antivirus enabled : $($status.AntivirusEnabled)" + Write-Host " Real-time protection : $($status.RealTimeProtectionEnabled)" + Write-Host " Tamper protection : $($status.IsTamperProtected)" + Write-Host " Signature age (days) : $($status.AntivirusSignatureAge)" + } catch { + # Reporting only. + } + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# LLMNR + NetBIOS +# ═══════════════════════════════════════════════════════════════════════════ + +if ($DoNameResolution) { + Write-Host '' + Show-LxsBoxMid 'NAME RESOLUTION' + Write-Host '' + if (Set-LxsRegistryValue -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient' ` + -Name 'EnableMulticast' -Value 0) { + Write-LxsOk 'LLMNR disabled' + $changes++ + } else { + $problems++ + } + + # NetbiosOptions: 0 = DHCP default, 1 = enabled, 2 = disabled. + $nbPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\NetBT\Parameters\Interfaces' + try { + $interfaces = @(Get-ChildItem $nbPath -ErrorAction Stop) + foreach ($iface in $interfaces) { + Set-ItemProperty -Path $iface.PSPath -Name 'NetbiosOptions' -Value 2 -ErrorAction SilentlyContinue + } + Write-LxsOk "NetBIOS-over-TCP/IP disabled on $($interfaces.Count) interface(s)" + $changes++ + } catch { + Write-LxsWarn "Could not disable NetBIOS-over-TCP/IP: $($_.Exception.Message)" + } +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Account audit — reports only, changes nothing. +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host '' +Show-LxsBoxMid 'ACCOUNT AUDIT' +Write-Host '' + +try { + $admins = @(Get-LocalGroupMember -Group 'Administrators' -ErrorAction Stop) + Write-Host "$($script:Cyan)Local administrators:$($script:NC)" + foreach ($a in $admins) { Write-Host " - $($a.Name) $($script:Gray)($($a.ObjectClass), $($a.PrincipalSource))$($script:NC)" } + if ($admins.Count -gt 2) { + Write-Host '' + Write-LxsWarn "$($admins.Count) accounts have administrator rights — review whether they all need it." + } +} catch { + Write-LxsWarn "Could not enumerate the Administrators group: $($_.Exception.Message)" +} + +Write-Host '' +try { + $locals = @(Get-LocalUser -ErrorAction Stop) + $enabledNoExpiry = @($locals | Where-Object { $_.Enabled -and $_.PasswordNeverExpires }) + $noPassword = @($locals | Where-Object { $_.Enabled -and -not $_.PasswordLastSet -and $_.Name -ne 'DefaultAccount' }) + $guest = $locals | Where-Object { $_.Name -eq 'Guest' } + + if ($noPassword.Count -gt 0) { + Write-LxsWarn "Enabled accounts that have never set a password: $($noPassword.Name -join ', ')" + } else { + Write-LxsOk 'No enabled account is missing a password' + } + if ($enabledNoExpiry.Count -gt 0) { + Write-Host "$($script:Gray) Password never expires: $($enabledNoExpiry.Name -join ', ')$($script:NC)" + } + if ($guest -and $guest.Enabled) { + Write-LxsWarn 'The Guest account is ENABLED — disable it unless you rely on it.' + } else { + Write-LxsOk 'Guest account is disabled' + } +} catch { + Write-LxsWarn "Could not enumerate local users: $($_.Exception.Message)" +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Summary +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host '' +Show-LxsSeparator +Write-Host '' +Write-LxsOk "$changes hardening change(s) applied" +if ($problems -gt 0) { + Write-LxsWarn "$problems change(s) failed — see the messages above." +} +if ($DoSmb) { + Write-LxsWarn 'Reboot to finish removing the SMBv1 client feature.' +} +Write-Host '' +exit 0 diff --git a/windows/tools/index.ps1 b/windows/tools/index.ps1 new file mode 100644 index 0000000..bc73b41 --- /dev/null +++ b/windows/tools/index.ps1 @@ -0,0 +1,75 @@ +<# + LXS - Tools index (Windows) + Description: Interactive menu listing the scripts in windows\tools + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +function Show-LxsToolsMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'TOOLS' -Right 'WIN_TOOLBOX' + Write-Host '' + Show-LxsMenuItem '01' 'System Infos' 'hardware, os, processes' + Show-LxsMenuItem '02' 'Network Diag' 'ip, dns, ports, repair' + Show-LxsMenuItem '03' 'Disk Cleanup' 'temp, update cache, winsxs' + Show-LxsMenuItem '04' 'System Repair' 'sfc, dism, chkdsk' + Show-LxsMenuItem '05' 'Restore Point' 'create, list, roll back' + Show-LxsMenuItem '06' 'Update Windows' 'windows update + winget' + Show-LxsMenuItem '07' 'Harden Windows' 'firewall, smb, defender' + Show-LxsMenuItem '08' 'Remote Access' 'rdp + openssh server' + Show-LxsMenuItem '09' 'Debloat' 'remove apps, telemetry off' + Show-LxsMenuItem '00' 'BACK' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + + $script = switch -Regex ($choice) { + '^0?1$' { 'tools\system-info.ps1' } + '^0?2$' { 'tools\net-diag.ps1' } + '^0?3$' { 'tools\cleanup.ps1' } + '^0?4$' { 'tools\repair.ps1' } + '^0?5$' { 'tools\restore-point.ps1' } + '^0?6$' { 'tools\update-windows.ps1' } + '^0?7$' { 'tools\harden.ps1' } + '^0?8$' { 'tools\remote-access.ps1' } + '^0?9$' { 'tools\debloat.ps1' } + '^0?0$' { 'BACK' } + default { $null } + } + + if ($script -eq 'BACK') { return } + if (-not $script) { + Write-LxsErr 'Invalid protocol. Select 0-9.' + Start-Sleep -Seconds 1 + continue + } + + $code = Invoke-LxsSibling -RelativePath $script -SelfDirectory $PSScriptRoot + + # Exit code 75 from a child means it already paused on its own + # (its own "Back" or end-of-run prompt) — skip the redundant prompt. + if ($code -ne 75) { Read-LxsEnter } + } +} + +Show-LxsToolsMenu +exit 75 diff --git a/windows/tools/net-diag.ps1 b/windows/tools/net-diag.ps1 new file mode 100644 index 0000000..e671178 --- /dev/null +++ b/windows/tools/net-diag.ps1 @@ -0,0 +1,197 @@ +<# + LXS - Network Diagnostics (Windows) + Description: Inspect and repair the network stack. + Everything is read-only except option 8 (stack reset). + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_net_diag.log' + +function Show-LxsIpConfiguration { + Clear-Host + Show-LxsBoxTop -Title 'IP CONFIGURATION' + Write-Host '' + try { + Get-NetIPConfiguration -Detailed -ErrorAction Stop | ForEach-Object { + Write-Host "$($script:Cyan)$($script:Bold)$($_.InterfaceAlias)$($script:NC) $($script:Gray)($($_.InterfaceDescription))$($script:NC)" + Write-Host " Status : $($_.NetAdapter.Status) | MAC: $($_.NetAdapter.MacAddress) | Speed: $($_.NetAdapter.LinkSpeed)" + Write-Host " IPv4 : $($_.IPv4Address.IPAddress -join ', ')" + Write-Host " IPv6 : $($_.IPv6Address.IPAddress -join ', ')" + Write-Host " Gateway : $($_.IPv4DefaultGateway.NextHop -join ', ')" + Write-Host " DNS : $($_.DNSServer.ServerAddresses -join ', ')" + Write-Host '' + } + } catch { + Write-LxsWarn 'Get-NetIPConfiguration unavailable; falling back to ipconfig /all.' + ipconfig /all | Out-String | Write-Host + } +} + +function Show-LxsListeningPorts { + Clear-Host + Show-LxsBoxTop -Title 'LISTENING PORTS' + Write-Host '' + try { + $rows = Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { + $procName = try { (Get-Process -Id $_.OwningProcess -ErrorAction Stop).ProcessName } catch { 'unknown' } + [pscustomobject]@{ + Local = "$($_.LocalAddress):$($_.LocalPort)" + Port = $_.LocalPort + PID = $_.OwningProcess + Process = $procName + } + } + $rows | Sort-Object Port | Format-Table -AutoSize Local, PID, Process | Out-String | Write-Host + } catch { + Write-LxsWarn 'Get-NetTCPConnection unavailable; falling back to netstat.' + netstat -ano | Select-String 'LISTENING' | Out-String | Write-Host + } +} + +function Clear-LxsDnsCache { + Clear-Host + Show-LxsBoxTop -Title 'FLUSH DNS CACHE' + Write-Host '' + try { + Clear-DnsClientCache -ErrorAction Stop + Write-LxsOk 'DNS resolver cache flushed' + } catch { + Write-LxsWarn 'Clear-DnsClientCache failed; falling back to ipconfig /flushdns.' + ipconfig /flushdns | Out-String | Write-Host + } +} + +function Invoke-LxsPing { + Clear-Host + Show-LxsBoxTop -Title 'PING' + Write-Host '' + $target = Read-Host -Prompt 'Host to ping (default: 1.1.1.1)' + if ([string]::IsNullOrWhiteSpace($target)) { $target = '1.1.1.1' } + Write-Host '' + ping.exe -n 4 $target | Out-String | Write-Host +} + +function Invoke-LxsTraceroute { + Clear-Host + Show-LxsBoxTop -Title 'TRACEROUTE' + Write-Host '' + $target = Read-Host -Prompt 'Host to trace (default: 1.1.1.1)' + if ([string]::IsNullOrWhiteSpace($target)) { $target = '1.1.1.1' } + Write-Host '' + Write-LxsInfo "Tracing route to $target (this can take a minute)..." + tracert.exe -d -h 20 $target | Out-String | Write-Host +} + +function Test-LxsRemotePort { + Clear-Host + Show-LxsBoxTop -Title 'TEST TCP PORT' + Write-Host '' + $target = Read-Host -Prompt 'Host' + if ([string]::IsNullOrWhiteSpace($target)) { Write-LxsErr 'No host given.'; return } + $port = Read-Host -Prompt 'Port' + if ($port -notmatch '^\d+$') { Write-LxsErr 'Port must be a number.'; return } + Write-Host '' + try { + $result = Test-NetConnection -ComputerName $target -Port ([int]$port) -WarningAction SilentlyContinue -ErrorAction Stop + if ($result.TcpTestSucceeded) { + Write-LxsOk "${target}:${port} is reachable ($($result.RemoteAddress))" + } else { + Write-LxsErr "${target}:${port} is NOT reachable" + } + } catch { + Write-LxsErr "Test failed: $($_.Exception.Message)" + } +} + +function Show-LxsPublicIp { + Clear-Host + Show-LxsBoxTop -Title 'PUBLIC IP' + Write-Host '' + Write-LxsInfo 'Querying...' + Write-Host '' + Write-Host " $($script:White)$($script:Bold)$(Get-LxsPublicIP)$($script:NC)" + Write-Host '' +} + +function Reset-LxsNetworkStack { + Clear-Host + Show-LxsBoxTop -Title 'RESET NETWORK STACK' -Right 'DESTRUCTIVE' + Write-Host '' + Write-Host 'This resets the Winsock catalog and the TCP/IP stack. It clears' + Write-Host 'third-party LSP entries, static IP settings and proxy configuration,' + Write-Host "and $($script:Bold)requires a reboot$($script:NC) to take effect." + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Reset the network stack now?')) { + Write-LxsInfo 'Cancelled.' + return + } + if (-not (Test-LxsAdmin)) { + Write-LxsErr 'This action requires administrator rights. Re-run lxs from an elevated terminal.' + return + } + + Write-Host '' + netsh winsock reset | Out-String | Write-Host + netsh int ip reset | Out-String | Write-Host + Clear-DnsClientCache -ErrorAction SilentlyContinue + Write-Host '' + Write-LxsOk 'Network stack reset' + Write-LxsWarn 'Reboot required for the change to take effect.' +} + +function Show-LxsNetDiagMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'NETWORK DIAG' + Write-Host '' + Show-LxsMenuItem '1' 'Show IP configuration' + Show-LxsMenuItem '2' 'Show listening ports' + Show-LxsMenuItem '3' 'Flush DNS cache' + Show-LxsMenuItem '4' 'Ping a host' + Show-LxsMenuItem '5' 'Traceroute a host' + Show-LxsMenuItem '6' 'Test a TCP port' + Show-LxsMenuItem '7' 'Show public IP' + Show-LxsMenuItem '8' 'Reset network stack' 'needs admin + reboot' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsIpConfiguration; Read-LxsEnter } + '2' { Show-LxsListeningPorts; Read-LxsEnter } + '3' { Clear-LxsDnsCache; Read-LxsEnter } + '4' { Invoke-LxsPing; Read-LxsEnter } + '5' { Invoke-LxsTraceroute; Read-LxsEnter } + '6' { Test-LxsRemotePort; Read-LxsEnter } + '7' { Show-LxsPublicIp; Read-LxsEnter } + '8' { Reset-LxsNetworkStack; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-8.'; Start-Sleep -Seconds 1 } + } + } +} + +if (-not (Assert-LxsWindows)) { exit 1 } +Show-LxsNetDiagMenu +exit 75 diff --git a/windows/tools/remote-access.ps1 b/windows/tools/remote-access.ps1 new file mode 100644 index 0000000..2b86901 --- /dev/null +++ b/windows/tools/remote-access.ps1 @@ -0,0 +1,300 @@ +<# + LXS - Remote Access (Windows) + Description: Remote Desktop and the OpenSSH server — status, enable, + disable. The Windows counterpart of root-ssh-login.sh. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_remote_access.log' + +if (-not (Assert-LxsWindows)) { exit 1 } +Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args + +$LxsRdpKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' +$LxsRdpTcpKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' + +function Get-LxsRdpState { + try { + # fDenyTSConnections 0 = RDP allowed, 1 = denied. + $deny = (Get-ItemProperty -Path $LxsRdpKey -Name 'fDenyTSConnections' -ErrorAction Stop).fDenyTSConnections + return ($deny -eq 0) + } catch { + return $false + } +} + +function Get-LxsNlaState { + try { + return ((Get-ItemProperty -Path $LxsRdpTcpKey -Name 'UserAuthentication' -ErrorAction Stop).UserAuthentication -eq 1) + } catch { + return $false + } +} + +function Show-LxsRemoteStatus { + Clear-Host + Show-LxsBoxTop -Title 'REMOTE ACCESS STATUS' + Write-Host '' + + Write-Host "$($script:Cyan)$($script:Bold)Remote Desktop$($script:NC)" + if (Get-LxsRdpState) { + Write-LxsOk 'RDP is ENABLED' + if (Get-LxsNlaState) { + Write-LxsOk 'Network Level Authentication is on' + } else { + Write-LxsWarn 'Network Level Authentication is OFF — turn it on (option 2).' + } + try { + $port = (Get-ItemProperty -Path $LxsRdpTcpKey -Name 'PortNumber' -ErrorAction Stop).PortNumber + Write-Host " Listening port: $port" + } catch { + Write-Host ' Listening port: 3389 (default)' + } + } else { + Write-Host " $($script:Gray)RDP is disabled$($script:NC)" + } + + try { + $rules = @(Get-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction Stop | Where-Object { $_.Enabled -eq 'True' }) + Write-Host " Firewall rules enabled: $($rules.Count)" + } catch { + # Group may not exist on Server Core images. + } + + Write-Host '' + Write-Host "$($script:Cyan)$($script:Bold)OpenSSH Server$($script:NC)" + $svc = Get-Service -Name sshd -ErrorAction SilentlyContinue + if (-not $svc) { + Write-Host " $($script:Gray)Not installed$($script:NC)" + } else { + if ($svc.Status -eq 'Running') { Write-LxsOk "sshd is running (startup: $($svc.StartType))" } + else { Write-LxsWarn "sshd is installed but $($svc.Status) (startup: $($svc.StartType))" } + try { + $shell = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name 'DefaultShell' -ErrorAction Stop).DefaultShell + Write-Host " Default shell : $shell" + } catch { + Write-Host " Default shell : cmd.exe (Windows default)" + } + } + Write-Host '' +} + +function Enable-LxsRdp { + Clear-Host + Show-LxsBoxTop -Title 'ENABLE REMOTE DESKTOP' + Write-Host '' + Write-Host 'This allows inbound RDP connections and opens the matching' + Write-Host 'firewall rules. Network Level Authentication will be required,' + Write-Host 'so clients must authenticate before a session is created.' + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Enable Remote Desktop?')) { + Write-LxsInfo 'Cancelled.' + return + } + Write-Host '' + if (Set-LxsRegistryValue -Path $LxsRdpKey -Name 'fDenyTSConnections' -Value 0) { + Write-LxsOk 'RDP connections allowed' + } + if (Set-LxsRegistryValue -Path $LxsRdpTcpKey -Name 'UserAuthentication' -Value 1) { + Write-LxsOk 'Network Level Authentication required' + } + try { + Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction Stop + Write-LxsOk 'Firewall rules for Remote Desktop enabled' + } catch { + Write-LxsWarn "Could not enable the firewall rules: $($_.Exception.Message)" + } + Write-Host '' + Write-LxsWarn 'Only expose RDP to the internet behind a VPN — it is a constant brute-force target.' +} + +function Disable-LxsRdp { + Clear-Host + Show-LxsBoxTop -Title 'DISABLE REMOTE DESKTOP' + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Disable Remote Desktop and its firewall rules?')) { + Write-LxsInfo 'Cancelled.' + return + } + Write-Host '' + if (Set-LxsRegistryValue -Path $LxsRdpKey -Name 'fDenyTSConnections' -Value 1) { + Write-LxsOk 'RDP connections denied' + } + try { + Disable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction Stop + Write-LxsOk 'Firewall rules for Remote Desktop disabled' + } catch { + Write-LxsWarn "Could not disable the firewall rules: $($_.Exception.Message)" + } +} + +function Install-LxsSshServer { + Clear-Host + Show-LxsBoxTop -Title 'OPENSSH SERVER' + Write-Host '' + Write-Host 'Installs the OpenSSH Server capability, starts sshd, sets it to' + Write-Host 'start automatically and opens TCP/22 in the firewall.' + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Install and enable the OpenSSH server?')) { + Write-LxsInfo 'Cancelled.' + return + } + Write-Host '' + + try { + $cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' -ErrorAction Stop | + Select-Object -First 1 + } catch { + Write-LxsErr "Could not query Windows capabilities: $($_.Exception.Message)" + return + } + + if (-not $cap) { + Write-LxsErr 'The OpenSSH Server capability is not available on this edition.' + return + } + + if ($cap.State -ne 'Installed') { + Write-LxsInfo "Installing $($cap.Name) (this can take a few minutes)..." + try { + Add-WindowsCapability -Online -Name $cap.Name -ErrorAction Stop | Out-Null + Write-LxsOk 'OpenSSH Server installed' + } catch { + Write-LxsErr "Installation failed: $($_.Exception.Message)" + return + } + } else { + Write-LxsOk 'OpenSSH Server was already installed' + } + + try { + Set-Service -Name sshd -StartupType Automatic -ErrorAction Stop + Start-Service -Name sshd -ErrorAction Stop + Write-LxsOk 'sshd is running and set to start automatically' + } catch { + Write-LxsErr "Could not start sshd: $($_.Exception.Message)" + return + } + + # The capability normally creates this rule; create it when it is missing. + try { + if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' ` + -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -ErrorAction Stop | Out-Null + Write-LxsOk 'Firewall rule created for TCP/22' + } else { + Write-LxsOk 'Firewall rule for TCP/22 already present' + } + } catch { + Write-LxsWarn "Could not create the firewall rule: $($_.Exception.Message)" + } + + Write-Host '' + Write-Host "$($script:Gray)Connect with: ssh $env:USERNAME@$(Get-LxsPublicIP)$($script:NC)" + Write-Host "$($script:Gray)Key-based auth: put your public key in %ProgramData%\ssh\administrators_authorized_keys$($script:NC)" + Write-Host "$($script:Gray)for admin accounts, or in %USERPROFILE%\.ssh\authorized_keys otherwise.$($script:NC)" +} + +function Disable-LxsSshServer { + Clear-Host + Show-LxsBoxTop -Title 'DISABLE OPENSSH SERVER' + Write-Host '' + $svc = Get-Service -Name sshd -ErrorAction SilentlyContinue + if (-not $svc) { + Write-LxsWarn 'The OpenSSH server is not installed.' + return + } + if (-not (Confirm-LxsAction -Question 'Stop sshd and set it to Disabled?')) { + Write-LxsInfo 'Cancelled.' + return + } + try { + Stop-Service -Name sshd -Force -ErrorAction Stop + Set-Service -Name sshd -StartupType Disabled -ErrorAction Stop + Write-LxsOk 'sshd stopped and disabled' + } catch { + Write-LxsErr "Failed: $($_.Exception.Message)" + } +} + +function Set-LxsSshDefaultShell { + Clear-Host + Show-LxsBoxTop -Title 'SSH DEFAULT SHELL' + Write-Host '' + Write-Host 'Which shell should SSH sessions land in?' + Write-Host '' + Show-LxsMenuItem '1' 'PowerShell' 'powershell.exe' + Show-LxsMenuItem '2' 'PowerShell 7' 'pwsh.exe (must be installed)' + Show-LxsMenuItem '3' 'Command Prompt' 'cmd.exe (Windows default)' + Show-LxsMenuItem '0' 'Cancel' '' -Exit + Write-Host '' + $choice = Read-LxsChoice + + $shell = switch ($choice) { + '1' { "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" } + '2' { (Get-Command pwsh -ErrorAction SilentlyContinue).Source } + '3' { "$env:SystemRoot\System32\cmd.exe" } + default { $null } + } + if (-not $shell) { + if ($choice -eq '2') { Write-LxsErr 'pwsh.exe was not found — install PowerShell 7 first.' } + else { Write-LxsInfo 'Cancelled.' } + return + } + Write-Host '' + if (Set-LxsRegistryValue -Path 'HKLM:\SOFTWARE\OpenSSH' -Name 'DefaultShell' -Value $shell -Type String) { + Write-LxsOk "SSH sessions will start: $shell" + } +} + +function Show-LxsRemoteMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'REMOTE ACCESS' -Right 'ADMIN' + Write-Host '' + Show-LxsMenuItem '1' 'Show status' + Show-LxsMenuItem '2' 'Enable Remote Desktop' 'with NLA + firewall' + Show-LxsMenuItem '3' 'Disable Remote Desktop' + Show-LxsMenuItem '4' 'Install OpenSSH Server' 'service + firewall' + Show-LxsMenuItem '5' 'Disable OpenSSH Server' + Show-LxsMenuItem '6' 'Set SSH default shell' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsRemoteStatus; Read-LxsEnter } + '2' { Enable-LxsRdp; Read-LxsEnter } + '3' { Disable-LxsRdp; Read-LxsEnter } + '4' { Install-LxsSshServer; Read-LxsEnter } + '5' { Disable-LxsSshServer; Read-LxsEnter } + '6' { Set-LxsSshDefaultShell; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-6.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsRemoteMenu +exit 75 diff --git a/windows/tools/repair.ps1 b/windows/tools/repair.ps1 new file mode 100644 index 0000000..144b4c7 --- /dev/null +++ b/windows/tools/repair.ps1 @@ -0,0 +1,150 @@ +<# + LXS - System Repair (Windows) + Description: Integrity repair for the component store and system files + (SFC, DISM, chkdsk). Requires administrator rights. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_repair.log' + +if (-not (Assert-LxsWindows)) { exit 1 } +Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args + +# These commands stream long-running progress; running them through the +# spinner would hide it, so they write straight to the console. +function Invoke-LxsRepairCommand { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Exe, + [Parameter(Mandatory = $true)][string[]]$CmdArgs, + [string]$Note = '' + ) + Clear-Host + Show-LxsBoxTop -Title $Title + Write-Host '' + if ($Note) { + Write-Host "$($script:Gray)$Note$($script:NC)" + Write-Host '' + } + Write-LxsInfo "$Exe $($CmdArgs -join ' ')" + Show-LxsSeparator + Write-Host '' + + & $Exe @CmdArgs + $code = $LASTEXITCODE + + Write-Host '' + Show-LxsSeparator + if ($code -eq 0) { + Write-LxsOk "$Title completed" + } else { + Write-LxsWarn "$Title exited with code $code" + } + return $code +} + +function Invoke-LxsSfc { + Invoke-LxsRepairCommand -Title 'SFC SCANNOW' -Exe 'sfc.exe' -CmdArgs @('/scannow') ` + -Note 'Verifies every protected system file and repairs corrupted ones from the component store. Takes 5-15 minutes.' | Out-Null +} + +function Invoke-LxsDism { + Clear-Host + Show-LxsBoxTop -Title 'DISM RESTOREHEALTH' + Write-Host '' + Write-Host "$($script:Gray)Repairs the component store itself. Run this before SFC when SFC$($script:NC)" + Write-Host "$($script:Gray)reports that it cannot fix files. Needs internet access for the$($script:NC)" + Write-Host "$($script:Gray)replacement payloads. Takes 10-30 minutes.$($script:NC)" + Write-Host '' + Show-LxsSeparator + Write-Host '' + + Write-LxsInfo 'Step 1/3 - CheckHealth' + & dism.exe /Online /Cleanup-Image /CheckHealth + Write-Host '' + Write-LxsInfo 'Step 2/3 - ScanHealth (slow)' + & dism.exe /Online /Cleanup-Image /ScanHealth + Write-Host '' + Write-LxsInfo 'Step 3/3 - RestoreHealth' + & dism.exe /Online /Cleanup-Image /RestoreHealth + $code = $LASTEXITCODE + + Write-Host '' + Show-LxsSeparator + if ($code -eq 0) { Write-LxsOk 'DISM completed' } else { Write-LxsWarn "DISM exited with code $code" } +} + +function Invoke-LxsChkdsk { + Invoke-LxsRepairCommand -Title 'CHKDSK SCAN' -Exe 'chkdsk.exe' -CmdArgs @($env:SystemDrive, '/scan') ` + -Note 'Online, read-only scan of the system volume. It reports problems without taking the volume offline; a full /f repair needs a reboot.' | Out-Null +} + +function Invoke-LxsFullRepair { + Clear-Host + Show-LxsBoxTop -Title 'FULL REPAIR PASS' + Write-Host '' + Write-Host 'Runs, in the recommended order:' + Write-Host ' 1. DISM /RestoreHealth (repair the component store)' + Write-Host ' 2. SFC /scannow (repair system files from that store)' + Write-Host ' 3. chkdsk /scan (check the file system)' + Write-Host '' + Write-Host "$($script:Gray)Budget 30-60 minutes. Nothing here is destructive.$($script:NC)" + Write-Host '' + if (-not (Confirm-LxsAction -Question 'Start the full repair pass?' -DefaultYes)) { + Write-LxsInfo 'Cancelled.' + return + } + Invoke-LxsDism + Read-LxsEnter + Invoke-LxsSfc + Read-LxsEnter + Invoke-LxsChkdsk +} + +function Show-LxsRepairMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'SYSTEM REPAIR' -Right 'ADMIN' + Write-Host '' + Show-LxsMenuItem '1' 'SFC /scannow' 'repair system files' + Show-LxsMenuItem '2' 'DISM /RestoreHealth' 'repair component store' + Show-LxsMenuItem '3' 'chkdsk /scan' 'check the file system' + Show-LxsMenuItem '4' 'Full repair pass' 'DISM, then SFC, then chkdsk' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Invoke-LxsSfc; Read-LxsEnter } + '2' { Invoke-LxsDism; Read-LxsEnter } + '3' { Invoke-LxsChkdsk; Read-LxsEnter } + '4' { Invoke-LxsFullRepair; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-4.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsRepairMenu +exit 75 diff --git a/windows/tools/restore-point.ps1 b/windows/tools/restore-point.ps1 new file mode 100644 index 0000000..b38f045 --- /dev/null +++ b/windows/tools/restore-point.ps1 @@ -0,0 +1,177 @@ +<# + LXS - System Restore Point (Windows) + Description: Enable System Protection, create checkpoints, list them and + roll back. The safety net for harden.ps1 and debloat.ps1. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_restore_point.log' + +if (-not (Assert-LxsWindows)) { exit 1 } +Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args + +function Get-LxsRestorePoints { + try { + return @(Get-ComputerRestorePoint -ErrorAction Stop) + } catch { + return @() + } +} + +function Show-LxsRestorePoints { + Clear-Host + Show-LxsBoxTop -Title 'RESTORE POINTS' + Write-Host '' + $points = Get-LxsRestorePoints + if ($points.Count -eq 0) { + Write-LxsWarn 'No restore points found (System Protection may be disabled).' + return + } + $points | Sort-Object SequenceNumber -Descending | + Format-Table -AutoSize ` + @{ Name = 'Seq'; Expression = { $_.SequenceNumber } }, + @{ Name = 'Created'; Expression = { $_.ConvertToDateTime($_.CreationTime).ToString('yyyy-MM-dd HH:mm') } }, + @{ Name = 'Type'; Expression = { $_.RestorePointType } }, + @{ Name = 'Description'; Expression = { $_.Description } } | + Out-String -Width 160 | Write-Host +} + +function Show-LxsProtectionStatus { + Clear-Host + Show-LxsBoxTop -Title 'SYSTEM PROTECTION' + Write-Host '' + $drive = "$env:SystemDrive\" + try { + # Volumes listed here have shadow storage configured, i.e. protection on. + $shadow = @(Get-CimInstance Win32_ShadowStorage -ErrorAction Stop) + if ($shadow.Count -gt 0) { + Write-LxsOk "System Protection appears to be ON for $($shadow.Count) volume(s)" + foreach ($s in $shadow) { + $maxGB = [math]::Round($s.MaxSpace / 1GB, 1) + $usedGB = [math]::Round($s.UsedSpace / 1GB, 2) + Write-Host " Shadow storage: $usedGB GB used of $maxGB GB allocated" + } + } else { + Write-LxsWarn "System Protection looks OFF for $drive — no restore points can be created." + } + } catch { + Write-LxsWarn 'Could not read the shadow storage configuration.' + } + Write-Host '' + Write-Host "$($script:Gray)Restore points found: $((Get-LxsRestorePoints).Count)$($script:NC)" + Write-Host '' +} + +function Enable-LxsProtection { + Clear-Host + Show-LxsBoxTop -Title 'ENABLE SYSTEM PROTECTION' + Write-Host '' + $drive = "$env:SystemDrive\" + try { + Enable-ComputerRestore -Drive $drive -ErrorAction Stop + Write-LxsOk "System Protection enabled on $drive" + } catch { + Write-LxsErr "Failed to enable System Protection: $($_.Exception.Message)" + return + } + # Default shadow storage can be as low as 1%; 5% leaves room for a few points. + try { + & vssadmin.exe Resize ShadowStorage /For=$drive /On=$drive /MaxSize=5% | Out-String | Write-Host + } catch { + Write-LxsWarn 'Could not resize the shadow storage allocation.' + } +} + +function New-LxsCheckpoint { + Clear-Host + Show-LxsBoxTop -Title 'CREATE RESTORE POINT' + Write-Host '' + $desc = Read-Host -Prompt 'Description (default: LXS manual checkpoint)' + if ([string]::IsNullOrWhiteSpace($desc)) { $desc = 'LXS manual checkpoint' } + Write-Host '' + Write-LxsInfo 'Creating the restore point (this can take a minute)...' + New-LxsRestorePoint -Description $desc | Out-Null +} + +function Restore-LxsCheckpoint { + Clear-Host + Show-LxsBoxTop -Title 'ROLL BACK' -Right 'REBOOTS' + Write-Host '' + $points = Get-LxsRestorePoints + if ($points.Count -eq 0) { + Write-LxsWarn 'No restore points to roll back to.' + return + } + Show-LxsRestorePoints + Write-Host "$($script:Red)Restoring reboots the machine immediately and reverts system files,$($script:NC)" + Write-Host "$($script:Red)drivers, registry and installed programs to that point in time.$($script:NC)" + Write-Host "$($script:Gray)Your documents are not touched.$($script:NC)" + Write-Host '' + $seq = Read-Host -Prompt 'Sequence number to restore (empty to cancel)' + if ([string]::IsNullOrWhiteSpace($seq)) { Write-LxsInfo 'Cancelled.'; return } + if ($seq -notmatch '^\d+$') { Write-LxsErr 'Sequence number must be numeric.'; return } + if (-not ($points | Where-Object { $_.SequenceNumber -eq [int]$seq })) { + Write-LxsErr "No restore point with sequence number $seq." + return + } + if (-not (Confirm-LxsAction -Question "Restore to point $seq and reboot NOW?")) { + Write-LxsInfo 'Cancelled.' + return + } + try { + Restore-Computer -RestorePoint ([int]$seq) -Confirm:$false -ErrorAction Stop + } catch { + Write-LxsErr "Restore failed: $($_.Exception.Message)" + Write-Host "$($script:Gray) You can also use: rstrui.exe$($script:NC)" + } +} + +function Show-LxsRestoreMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'RESTORE POINT' -Right 'ADMIN' + Write-Host '' + Show-LxsMenuItem '1' 'Show protection status' + Show-LxsMenuItem '2' 'Enable System Protection' + Show-LxsMenuItem '3' 'Create a restore point' + Show-LxsMenuItem '4' 'List restore points' + Show-LxsMenuItem '5' 'Roll back to a restore point' 'reboots the machine' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsProtectionStatus; Read-LxsEnter } + '2' { Enable-LxsProtection; Read-LxsEnter } + '3' { New-LxsCheckpoint; Read-LxsEnter } + '4' { Show-LxsRestorePoints; Read-LxsEnter } + '5' { Restore-LxsCheckpoint; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-5.'; Start-Sleep -Seconds 1 } + } + } +} + +Show-LxsRestoreMenu +exit 75 diff --git a/windows/tools/system-info.ps1 b/windows/tools/system-info.ps1 new file mode 100644 index 0000000..211b9b0 --- /dev/null +++ b/windows/tools/system-info.ps1 @@ -0,0 +1,277 @@ +<# + LXS - System Infos (Windows) + Description: Essential system monitoring and diagnostic views. + Read-only: nothing here changes the machine. + Repo: https://git.hyko.cx/hykocx/lxs +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_system_infos.log' + +function Write-LxsField { + param([string]$Label, $Value) + Write-Host "$($script:Cyan)$($script:Bold)${Label}:$($script:NC)" + foreach ($line in @($Value)) { Write-Host " $line" } + Write-Host '' +} + +function Show-LxsSystemInformation { + Clear-Host + Show-LxsBoxTop -Title 'SYSTEM INFORMATION' + Write-Host '' + + try { + $os = Get-CimInstance Win32_OperatingSystem + $cs = Get-CimInstance Win32_ComputerSystem + $cpu = @(Get-CimInstance Win32_Processor) + $bios = Get-CimInstance Win32_BIOS + + Write-LxsField 'Operating System' "$($os.Caption) $($os.OSArchitecture)" + Write-LxsField 'Version / Build' "$($os.Version) (build $($os.BuildNumber))" + Write-LxsField 'Installed On' $os.InstallDate + $span = (Get-Date) - $os.LastBootUpTime + Write-LxsField 'System Uptime' ("{0} days, {1} hours, {2} minutes" -f $span.Days, $span.Hours, $span.Minutes) + Write-LxsField 'Hostname' $env:COMPUTERNAME + Write-LxsField 'Manufacturer' "$($cs.Manufacturer) $($cs.Model)" + Write-LxsField 'BIOS' "$($bios.Manufacturer) $($bios.SMBIOSBIOSVersion)" + Write-LxsField 'CPU' ($cpu | ForEach-Object { "$($_.Name.Trim()) ($($_.NumberOfCores)C/$($_.NumberOfLogicalProcessors)T)" }) + Write-LxsField 'Memory' ("{0} GB total" -f [math]::Round($cs.TotalPhysicalMemory / 1GB, 1)) + Write-LxsField 'PowerShell' $PSVersionTable.PSVersion.ToString() + } catch { + Write-LxsErr "Could not read system information: $($_.Exception.Message)" + } + + try { + $gpu = @(Get-CimInstance Win32_VideoController -ErrorAction Stop | + ForEach-Object { "$($_.Name) (driver $($_.DriverVersion))" }) + if ($gpu.Count -gt 0) { Write-LxsField 'GPU' $gpu } + } catch { + # Headless or restricted WMI — not worth failing the whole view. + } + + # Activation status. LicenseStatus 1 = licensed. + try { + $lic = @(Get-CimInstance SoftwareLicensingProduct -ErrorAction Stop | + Where-Object { $_.PartialProductKey -and $_.Name -like 'Windows*' } | Select-Object -First 1) + if ($lic.Count -gt 0) { + $state = switch ([int]$lic[0].LicenseStatus) { + 0 { 'Unlicensed' } + 1 { 'Licensed' } + 2 { 'Out-of-box grace period' } + 3 { 'Out-of-tolerance grace period' } + 4 { 'Non-genuine grace period' } + 5 { 'Notification (not activated)' } + 6 { 'Extended grace' } + default { 'Unknown' } + } + Write-LxsField 'Activation' "$state — $($lic[0].Name)" + } + } catch { + # SoftwareLicensingProduct is unavailable on some SKUs. + } +} + +function Show-LxsDiskSpace { + Clear-Host + Show-LxsBoxTop -Title 'DISK SPACE' + Write-Host '' + Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object { + $totalGB = [math]::Round($_.Size / 1GB, 1) + $freeGB = [math]::Round($_.FreeSpace / 1GB, 1) + $usedGB = [math]::Round(($_.Size - $_.FreeSpace) / 1GB, 1) + $pct = if ($_.Size -gt 0) { [math]::Round(100 * ($_.Size - $_.FreeSpace) / $_.Size) } else { 0 } + $bar = ('#' * [math]::Round($pct / 5)).PadRight(20, '.') + $color = if ($pct -ge 90) { $script:Red } else { $script:Cyan } + Write-Host (" $($script:White)$($script:Bold){0,-4}$($script:NC) {1}[{2}]$($script:NC) {3,6} GB used / {4,6} GB free / {5,6} GB total ({6}%)" -f ` + $_.DeviceID, $color, $bar, $usedGB, $freeGB, $totalGB, $pct) + } + Write-Host '' +} + +function Show-LxsMemoryUsage { + Clear-Host + Show-LxsBoxTop -Title 'MEMORY USAGE' + Write-Host '' + $os = Get-CimInstance Win32_OperatingSystem + $totalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2) + $freeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2) + $usedGB = [math]::Round($totalGB - $freeGB, 2) + $pct = if ($totalGB -gt 0) { [math]::Round(100 * $usedGB / $totalGB) } else { 0 } + Write-LxsField 'Physical Memory' "$usedGB GB used / $freeGB GB free / $totalGB GB total ($pct%)" + + $pageTotal = [math]::Round($os.SizeStoredInPagingFiles / 1MB, 2) + $pageFree = [math]::Round($os.FreeSpaceInPagingFiles / 1MB, 2) + Write-LxsField 'Paging File' "$([math]::Round($pageTotal - $pageFree, 2)) GB used / $pageTotal GB total" + + Write-Host "$($script:Cyan)$($script:Bold)Top 10 processes by memory:$($script:NC)" + Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 | + Format-Table -AutoSize @{ Name = 'PID'; Expression = { $_.Id } }, + @{ Name = 'Name'; Expression = { $_.ProcessName } }, + @{ Name = 'Memory (MB)'; Expression = { [math]::Round($_.WorkingSet64 / 1MB, 1) } } | + Out-String | Write-Host +} + +function Show-LxsCpuLoad { + Clear-Host + Show-LxsBoxTop -Title 'CPU LOAD' + Write-Host '' + try { + $load = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average + Write-LxsField 'Current Load' "$load%" + } catch { + Write-LxsWarn 'Could not read the CPU load counter.' + } + Write-LxsField 'Logical Processors' $env:NUMBER_OF_PROCESSORS + + Write-Host "$($script:Cyan)$($script:Bold)Top 10 processes by CPU time:$($script:NC)" + Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 | + Format-Table -AutoSize @{ Name = 'PID'; Expression = { $_.Id } }, + @{ Name = 'Name'; Expression = { $_.ProcessName } }, + @{ Name = 'CPU (s)'; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 1) } else { 0 } } } | + Out-String | Write-Host +} + +function Show-LxsNetwork { + Clear-Host + Show-LxsBoxTop -Title 'NETWORK' + Write-Host '' + try { + Get-NetIPConfiguration -ErrorAction Stop | ForEach-Object { + Write-Host "$($script:Cyan)$($script:Bold)$($_.InterfaceAlias)$($script:NC) $($script:Gray)($($_.InterfaceDescription))$($script:NC)" + Write-Host " IPv4 : $($_.IPv4Address.IPAddress -join ', ')" + Write-Host " Gateway : $($_.IPv4DefaultGateway.NextHop -join ', ')" + Write-Host " DNS : $($_.DNSServer.ServerAddresses -join ', ')" + Write-Host '' + } + } catch { + Write-LxsWarn 'Get-NetIPConfiguration unavailable; falling back to ipconfig.' + ipconfig /all | Out-String | Write-Host + } + Write-LxsField 'Public IP' (Get-LxsPublicIP) +} + +function Show-LxsEventLogs { + Clear-Host + Show-LxsBoxTop -Title 'SYSTEM LOGS' -Right 'LAST 50' + Write-Host '' + foreach ($logName in @('System', 'Application')) { + Write-Host "$($script:Cyan)$($script:Bold)$logName — errors and warnings$($script:NC)" + try { + $events = Get-WinEvent -FilterHashtable @{ LogName = $logName; Level = 1, 2, 3 } -MaxEvents 25 -ErrorAction Stop + $events | Format-Table -AutoSize -Wrap ` + @{ Name = 'Time'; Expression = { $_.TimeCreated.ToString('MM-dd HH:mm') } }, + @{ Name = 'Level'; Expression = { $_.LevelDisplayName } }, + @{ Name = 'Source'; Expression = { $_.ProviderName } }, + @{ Name = 'Message'; Expression = { ($_.Message -split "`n")[0] } } | + Out-String -Width 160 | Write-Host + } catch { + Write-LxsWarn "No matching entries in $logName (or access denied)." + } + Write-Host '' + } +} + +function Show-LxsTopProcesses { + Clear-Host + Show-LxsBoxTop -Title 'TOP PROCESSES' + Write-Host '' + Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 20 | + Format-Table -AutoSize @{ Name = 'PID'; Expression = { $_.Id } }, + @{ Name = 'Name'; Expression = { $_.ProcessName } }, + @{ Name = 'Memory (MB)'; Expression = { [math]::Round($_.WorkingSet64 / 1MB, 1) } }, + @{ Name = 'CPU (s)'; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 1) } else { 0 } } }, + @{ Name = 'Threads'; Expression = { $_.Threads.Count } } | + Out-String | Write-Host +} + +function Show-LxsDiskHealth { + Clear-Host + Show-LxsBoxTop -Title 'DISK HEALTH' -Right 'SMART' + Write-Host '' + try { + $disks = @(Get-PhysicalDisk -ErrorAction Stop) + } catch { + Write-LxsErr 'Get-PhysicalDisk is unavailable on this system.' + return + } + + foreach ($disk in $disks) { + $sizeGB = [math]::Round($disk.Size / 1GB, 1) + $healthColor = if ($disk.HealthStatus -eq 'Healthy') { $script:Cyan } else { $script:Red } + Write-Host "$($script:White)$($script:Bold)$($disk.FriendlyName)$($script:NC) $($script:Gray)// $($disk.MediaType), $sizeGB GB$($script:NC)" + Write-Host " Health : $healthColor$($disk.HealthStatus)$($script:NC) (operational: $($disk.OperationalStatus -join ', '))" + + # Reliability counters need admin; report the gap instead of failing. + try { + $rc = $disk | Get-StorageReliabilityCounter -ErrorAction Stop + if ($null -ne $rc.Temperature) { Write-Host " Temp : $($rc.Temperature) C" } + if ($null -ne $rc.Wear) { Write-Host " Wear : $($rc.Wear)%" } + if ($null -ne $rc.PowerOnHours) { Write-Host " Power on : $($rc.PowerOnHours) h" } + if ($null -ne $rc.ReadErrorsTotal) { Write-Host " Rd errors: $($rc.ReadErrorsTotal)" } + if ($null -ne $rc.WriteErrorsTotal) { Write-Host " Wr errors: $($rc.WriteErrorsTotal)" } + } catch { + if (-not (Test-LxsAdmin)) { + Write-Host "$($script:Gray) (run as administrator for SMART counters)$($script:NC)" + } else { + Write-Host "$($script:Gray) (this device exposes no reliability counters)$($script:NC)" + } + } + Write-Host '' + } +} + +function Show-LxsSystemInfoMenu { + while ($true) { + Clear-Host + Show-LxsBoxTop -Title 'SYSTEM INFOS' + Write-Host '' + Show-LxsMenuItem '1' 'View system information' + Show-LxsMenuItem '2' 'Check disk space' + Show-LxsMenuItem '3' 'Check memory usage' + Show-LxsMenuItem '4' 'Check CPU load' + Show-LxsMenuItem '5' 'Check network' + Show-LxsMenuItem '6' 'View system logs (errors and warnings)' + Show-LxsMenuItem '7' 'Show top resource-consuming processes' + Show-LxsMenuItem '8' 'Check disk health (SMART)' + Show-LxsMenuItem '0' 'Back' '' -Exit + Write-Host '' + Show-LxsBoxBottom + Write-Host '' + $choice = Read-LxsChoice + Write-Host '' + + switch ($choice) { + '1' { Show-LxsSystemInformation; Read-LxsEnter } + '2' { Show-LxsDiskSpace; Read-LxsEnter } + '3' { Show-LxsMemoryUsage; Read-LxsEnter } + '4' { Show-LxsCpuLoad; Read-LxsEnter } + '5' { Show-LxsNetwork; Read-LxsEnter } + '6' { Show-LxsEventLogs; Read-LxsEnter } + '7' { Show-LxsTopProcesses; Read-LxsEnter } + '8' { Show-LxsDiskHealth; Read-LxsEnter } + '0' { return } + default { Write-LxsErr 'Invalid protocol. Select 0-8.'; Start-Sleep -Seconds 1 } + } + } +} + +if (-not (Assert-LxsWindows)) { exit 1 } +Show-LxsSystemInfoMenu +exit 75 diff --git a/windows/tools/update-windows.ps1 b/windows/tools/update-windows.ps1 new file mode 100644 index 0000000..52363db --- /dev/null +++ b/windows/tools/update-windows.ps1 @@ -0,0 +1,220 @@ +<# + LXS - Update Windows + Description: Install pending Windows updates and upgrade every winget + package. Mirror of linux/tools/update-server.sh. + Repo: https://git.hyko.cx/hykocx/lxs + + Usage: update-windows.ps1 [-Yes] [-NoWinget] [-NoWindowsUpdate] [-Help] +#> + +# Load LXS common library (colors, UI helpers, spinner, loggers, guards). +# Prefers the sibling ..\lib\common.ps1 (repo checkout or installed layout) and +# only hits the network when this script is run standalone. +$LxsRawBase = if ($env:LXS_RAW_BASE) { $env:LXS_RAW_BASE } else { 'https://git.hyko.cx/hykocx/lxs/raw/branch/main' } +if (-not $env:LXS_RAW_PLATFORM_BASE) { $env:LXS_RAW_PLATFORM_BASE = "$LxsRawBase/windows" } +$LxsLibPath = if ($PSScriptRoot) { Join-Path $PSScriptRoot '..\lib\common.ps1' } else { $null } +if ($LxsLibPath -and (Test-Path $LxsLibPath)) { + . $LxsLibPath +} else { + try { + $LxsLibSource = Invoke-RestMethod -Uri "$env:LXS_RAW_PLATFORM_BASE/lib/common.ps1" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Error 'Failed to fetch lib/common.ps1' + exit 1 + } + . ([scriptblock]::Create($LxsLibSource)) +} + +$env:LXS_LOG_FILE = Join-Path (Get-LxsTempDir) 'lxs_update_windows.log' + +$LxsOpts = Read-LxsFlags -Arguments $args -Known @( + '-Yes', '-y', '-NoWinget', '-NoWindowsUpdate', '-Help', '-h' +) +if (Show-LxsUnknownFlags $LxsOpts) { exit 1 } +$AssumeYes = Test-LxsFlag $LxsOpts @('-Yes', '-y') +$NoWinget = Test-LxsFlag $LxsOpts @('-NoWinget') +$NoWindowsUpdate = Test-LxsFlag $LxsOpts @('-NoWindowsUpdate') + +if (Test-LxsFlag $LxsOpts @('-Help', '-h')) { + Write-Host @' +Usage: update-windows.ps1 [options] + +Options: + -Yes, -y Skip the confirmation prompt + -NoWinget Skip `winget upgrade --all` + -NoWindowsUpdate Skip Windows Update + -Help, -h Show this help +'@ + exit 0 +} + +if (-not (Assert-LxsWindows)) { exit 1 } +if (-not $NoWindowsUpdate) { Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args } + +# ═══════════════════════════════════════════════════════════════════════════ +# Windows Update via the built-in COM API. Deliberately not PSWindowsUpdate: +# that module has to be pulled from the PowerShell Gallery first, which fails +# on locked-down or offline machines. The COM surface ships with Windows. +# ═══════════════════════════════════════════════════════════════════════════ + +function Invoke-LxsWindowsUpdate { + Write-Host '' + Show-LxsBoxMid 'WINDOWS UPDATE' + Write-Host '' + + try { + $session = New-Object -ComObject Microsoft.Update.Session + $searcher = $session.CreateUpdateSearcher() + } catch { + Write-LxsErr "Windows Update API unavailable: $($_.Exception.Message)" + return $false + } + + Write-LxsInfo 'Searching for updates (this can take several minutes)...' + try { + $result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0") + } catch { + Write-LxsErr "Search failed: $($_.Exception.Message)" + return $false + } + + $updates = @($result.Updates) + if ($updates.Count -eq 0) { + Write-LxsOk 'No pending updates' + return $true + } + + Write-LxsOk "$($updates.Count) update(s) available:" + Write-Host '' + foreach ($u in $updates) { + $sizeMB = [math]::Round($u.MaxDownloadSize / 1MB, 1) + Write-Host " $($script:Gray)-$($script:NC) $($u.Title) $($script:Gray)(${sizeMB} MB)$($script:NC)" + } + Write-Host '' + + if (-not (Confirm-LxsAction -Question 'Download and install these updates?' -DefaultYes -AssumeYes:$AssumeYes)) { + Write-LxsInfo 'Skipped.' + return $true + } + + # EULAs must be accepted per-update before an unattended install. + $toInstall = New-Object -ComObject Microsoft.Update.UpdateColl + foreach ($u in $updates) { + if (-not $u.EulaAccepted) { + try { $u.AcceptEula() } catch { Write-LxsWarn "Could not accept the EULA for: $($u.Title)" } + } + [void]$toInstall.Add($u) + } + + Write-Host '' + Write-LxsInfo 'Downloading...' + try { + $downloader = $session.CreateUpdateDownloader() + $downloader.Updates = $toInstall + $dlResult = $downloader.Download() + if ($dlResult.ResultCode -ne 2) { + Write-LxsWarn "Download finished with result code $($dlResult.ResultCode) (2 = success)." + } else { + Write-LxsOk 'Download complete' + } + } catch { + Write-LxsErr "Download failed: $($_.Exception.Message)" + return $false + } + + Write-Host '' + Write-LxsInfo 'Installing...' + try { + $installer = $session.CreateUpdateInstaller() + $installer.Updates = $toInstall + $instResult = $installer.Install() + } catch { + Write-LxsErr "Install failed: $($_.Exception.Message)" + return $false + } + + for ($i = 0; $i -lt $toInstall.Count; $i++) { + $code = $instResult.GetUpdateResult($i).ResultCode + $title = $toInstall.Item($i).Title + if ($code -eq 2) { Write-LxsOk $title } else { Write-LxsWarn "$title (result code $code)" } + } + + Write-Host '' + if ($instResult.RebootRequired) { + Write-LxsWarn 'A reboot is required to finish installing these updates.' + } else { + Write-LxsOk 'Windows Update finished' + } + return $true +} + +# ═══════════════════════════════════════════════════════════════════════════ +# winget +# ═══════════════════════════════════════════════════════════════════════════ + +function Invoke-LxsWingetUpgrade { + Write-Host '' + Show-LxsBoxMid 'WINGET' + Write-Host '' + + if (-not (Assert-LxsWinget)) { return $false } + + Write-LxsInfo 'Packages with an available upgrade:' + Write-Host '' + & winget upgrade --include-unknown --accept-source-agreements | Out-String | Write-Host + + if (-not (Confirm-LxsAction -Question 'Upgrade all of them?' -DefaultYes -AssumeYes:$AssumeYes)) { + Write-LxsInfo 'Skipped.' + return $true + } + + Write-Host '' + & winget upgrade --all --include-unknown --silent ` + --accept-package-agreements --accept-source-agreements --disable-interactivity + $code = $LASTEXITCODE + + Write-Host '' + if ($code -eq 0) { + Write-LxsOk 'winget upgrade completed' + return $true + } + # -1978335189 = no applicable upgrade found; nothing actually went wrong. + if ($code -eq -1978335189) { + Write-LxsOk 'Everything is already up to date' + return $true + } + Write-LxsWarn "winget exited with code $code" + return $false +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Run +# ═══════════════════════════════════════════════════════════════════════════ + +Clear-Host +Show-LxsBoxTop -Title 'UPDATE WINDOWS' +Write-Host '' +Write-Host 'The following will run on this machine:' +if (-not $NoWindowsUpdate) { Write-Host ' - Windows Update: search, download and install pending updates' } +if (-not $NoWinget) { Write-Host ' - winget: upgrade every package with a newer version' } +Write-Host '' +Show-LxsSeparator + +if (-not (Confirm-LxsAction -Question 'Continue?' -DefaultYes -AssumeYes:$AssumeYes)) { + Write-LxsInfo 'Cancelled.' + exit 0 +} + +$failed = $false +if (-not $NoWindowsUpdate) { if (-not (Invoke-LxsWindowsUpdate)) { $failed = $true } } +if (-not $NoWinget) { if (-not (Invoke-LxsWingetUpgrade)) { $failed = $true } } + +Write-Host '' +Show-LxsSeparator +Write-Host '' +if ($failed) { + Write-LxsWarn 'Update pass finished with warnings.' + exit 1 +} +Write-LxsOk 'Update pass finished' +exit 0