Files
lxs/windows/lxs.ps1
T

503 lines
22 KiB
PowerShell

<#
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 <app> Install an application bundle
lxs tool <name> [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 <repo>/ 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