# MiniPad installer and updater, hosted at https://shakytext.com/minipad/install.ps1 # # irm https://shakytext.com/minipad/install.ps1 | iex # # Per user, no administrator rights. Re-running the same command is the update. # # - needs the .NET 10 SDK (MiniPad compiles scripts against it): checked, never installed for you # - downloads version.json and the payload parts (MiniPad.zip.001, .002, ...), checks every SHA-256, joins them # - extracts to %USERPROFILE%\MiniPad\versions\, then repoints the 'current' junction # (the Start menu shortcut and the optional PATH entry go through 'current', so they survive updates) # - keeps the last 2 versions; a running MiniPad is never killed: the script waits for you to close it # (MiniPad.Mcp.exe and MiniPad.Runner.exe may keep running during an update: restart your AI client afterwards) # - your scripts, history and settings (%USERPROFILE%\.minipad) are never touched, not even by -Uninstall # # Switches: # -AddToPath put the 'minipad' command (shim folder) on your user PATH # -AssociateFiles offer MiniPad in "Open with" for .linq and .csx # -NoShortcut no Start menu shortcut # -Launch start MiniPad when the install or update is done (the app's "Update now" passes this) # -Force reinstall even when this build is already installed # -Uninstall remove the install folder, the shortcut and the PATH entry (PATH is restored exactly) # -Source where version.json and the parts are (default https://shakytext.com/minipad; plain http only for localhost) # # For tests only: -InstallRoot, -ShortcutDir, -PathSubKey, -SkipSdkCheck, -WaitSeconds, -NoBroadcast, -FailSwapForTest. # # Everything runs inside one script block (with its function) that is called on the last line: a truncated download does nothing # (it does not even parse), and no preference or helper function is left in your session. The only trace is the .NET types that # Add-Type defines for this PowerShell process. # # (This file stays ASCII: Windows PowerShell 5.1 reads scripts as ANSI.) param( [string]$Source = "https://shakytext.com/minipad", [switch]$AddToPath, [switch]$AssociateFiles, [switch]$NoShortcut, [switch]$Launch, [switch]$Force, [switch]$Uninstall, [string]$InstallRoot, [string]$ShortcutDir, [string]$PathSubKey = "Environment", [switch]$SkipSdkCheck, [int]$WaitSeconds = 180, [switch]$NoBroadcast, [switch]$FailSwapForTest ) & { function Invoke-MiniPadInstall { $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is many times faster without the progress bar try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } # ---- pathedit.ps1, pasted in by package.ps1 -Site ---- # Helpers for editing the per-user PATH exactly: install.ps1 and uninstall.ps1 dot-source this file. # (This file stays ASCII: Windows PowerShell 5.1 reads scripts as ANSI.) # # Why not [Environment]::GetEnvironmentVariable / SetEnvironmentVariable: they hand back the EXPANDED value (so an entry # such as %USERPROFILE%\bin is replaced by its expanded form for good) and always write REG_SZ. These helpers read the raw # value from HKCU\Environment, keep its registry value kind (REG_EXPAND_SZ stays REG_EXPAND_SZ), keep empty segments and a # trailing ";", and only write when the text really changes. function Get-UserPathRaw { param([string]$SubKey = "Environment") $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($SubKey, $false) if (-not $key) { return [pscustomobject]@{ Value = $null; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString } } try { $value = $key.GetValue("Path", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($null -eq $value) { return [pscustomobject]@{ Value = $null; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString } } return [pscustomobject]@{ Value = [string]$value; Kind = $key.GetValueKind("Path") } } finally { $key.Dispose() } } function Set-UserPathRaw { param([string]$Value, [Microsoft.Win32.RegistryValueKind]$Kind, [string]$SubKey = "Environment") $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($SubKey) try { if ([string]::IsNullOrEmpty($Value)) { $key.DeleteValue("Path", $false) } else { $key.SetValue("Path", $Value, $Kind) } } finally { $key.Dispose() } } # Tells running programs (Explorer, new terminals) that the environment changed, like [Environment]::SetEnvironmentVariable does. function Send-EnvironmentChanged { # The type is looked up by name, not kept in a variable: nothing is left behind in the caller's session. $native = "MiniPadSetup.MiniPadNative" -as [type] if (-not $native) { $native = Add-Type -Name "MiniPadNative" -Namespace "MiniPadSetup" -PassThru -MemberDefinition @' [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint msg, System.UIntPtr wParam, string lParam, uint flags, uint timeout, out System.UIntPtr result); '@ } $result = [UIntPtr]::Zero # HWND_BROADCAST, WM_SETTINGCHANGE, SMTO_ABORTIFHUNG, 5 s [void]$native::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) } # True when a PATH segment names $Directory (ignores case and a trailing "\", and also matches %VAR% forms). function Test-PathSegment { param([string]$Segment, [string]$Directory) $dir = $Directory.TrimEnd("\") foreach ($candidate in @($Segment, [Environment]::ExpandEnvironmentVariables($Segment))) { if ($candidate.Trim().Trim('"').TrimEnd("\") -ieq $dir) { return $true } } return $false } # Pure text edit. Appends ";" (always with its own separator, so an original trailing ";" survives a later removal). function Add-PathEntryText { param([string]$Path, [string]$Directory) if ([string]::IsNullOrEmpty($Path)) { return $Directory } if ($Path.Split(";") | Where-Object { $_ -and (Test-PathSegment $_ $Directory) }) { return $Path } return $Path + ";" + $Directory } # Pure text edit. Removes every segment naming one of $Directories together with ONE neighbouring ";"; everything else, # empty segments and a trailing ";" included, stays byte for byte. function Remove-PathEntryText { param([string]$Path, [string[]]$Directories) if ([string]::IsNullOrEmpty($Path)) { return $Path } $segments = [System.Collections.Generic.List[string]]($Path.Split(";")) for ($i = $segments.Count - 1; $i -ge 0; $i--) { $segment = $segments[$i] if (-not $segment) { continue } $hit = $false foreach ($d in $Directories) { if (Test-PathSegment $segment $d) { $hit = $true; break } } if (-not $hit) { continue } # Taking the element out of the list takes exactly one ";" with it. $segments.RemoveAt($i) } return ($segments -join ";") } # Edit the user PATH in the registry. Returns $true when something was written. function Update-UserPath { param([string[]]$Add = @(), [string[]]$Remove = @(), [string]$SubKey = "Environment", [switch]$NoBroadcast) $current = Get-UserPathRaw -SubKey $SubKey $text = $current.Value if ($Remove.Count -gt 0) { $text = Remove-PathEntryText -Path $text -Directories $Remove } foreach ($directory in $Add) { $text = Add-PathEntryText -Path $text -Directory $directory } if ($text -ceq $current.Value) { return $false } Set-UserPathRaw -Value $text -Kind $current.Kind -SubKey $SubKey if (-not $NoBroadcast) { Send-EnvironmentChanged } return $true } # ---- Guards for uninstall.ps1 ---------------------------------------------------------------------------------------------- # A Setup.exe (Velopack) install and a zip install share some per-user names: the "Apps & features" key HKCU\...\Uninstall\MiniPad, # the Start menu shortcut MiniPad.lnk and the "Open with" registrations. uninstall.ps1 only removes what points at ITS folder. # True when the "Open with" registration runs $ExePath (the command is "" "%1"). function Test-FileTypesPointAt { param([string]$ExePath, [string]$ClassesKey = "Software\Classes") $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($ClassesKey + "\MiniPad.Script\shell\open\command", $false) if (-not $key) { return $false } try { $command = [string]$key.GetValue("") } finally { $key.Dispose() } return $command.StartsWith('"' + $ExePath + '"', [System.StringComparison]::OrdinalIgnoreCase) } # Deletes the "Apps & features" key only when its InstallLocation is $InstallDir. Returns $true when it was deleted. function Remove-OwnUninstallEntry { param([string]$InstallDir, [string]$KeyPath = "Software\Microsoft\Windows\CurrentVersion\Uninstall\MiniPad") $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($KeyPath, $false) if (-not $key) { return $false } try { $location = [string]$key.GetValue("InstallLocation") } finally { $key.Dispose() } if ($location.TrimEnd("\") -ine $InstallDir.TrimEnd("\")) { return $false } [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($KeyPath, $false) return $true } # Deletes the shortcut only when it starts $ExePath. Returns $true when it was deleted. function Remove-OwnShortcut { param([string]$LinkPath, [string]$ExePath) if (-not (Test-Path -LiteralPath $LinkPath)) { return $false } $shell = New-Object -ComObject WScript.Shell $target = $shell.CreateShortcut($LinkPath).TargetPath if ($target -ine $ExePath) { return $false } Remove-Item -LiteralPath $LinkPath -Force return $true } # ---- end of pathedit.ps1 ---- if (-not (Get-Command Update-UserPath -ErrorAction SilentlyContinue)) { $sibling = $null if ($PSScriptRoot) { $sibling = Join-Path $PSScriptRoot "pathedit.ps1" } if ($sibling -and (Test-Path -LiteralPath $sibling)) { . $sibling } else { throw "The PATH helpers are missing: this install.ps1 was not made by package.ps1 -Site." } } $root = $InstallRoot if (-not $root) { $root = Join-Path $env:USERPROFILE "MiniPad" } $root = [System.IO.Path]::GetFullPath($root).TrimEnd("\") $shortcuts = $ShortcutDir if (-not $shortcuts) { $shortcuts = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" } $versionsDir = Join-Path $root "versions" $currentDir = Join-Path $root "current" $shimDir = Join-Path $currentDir "shim" $linkPath = Join-Path $shortcuts "MiniPad.lnk" $markerPath = Join-Path $root ".minipad-install" $src = $Source.TrimEnd("/", "\") $isWeb = $src -match "^https?://" if ($isWeb) { # [uri] decides, not a pattern: "http://localhost:80@evil.test/" is evil.test. $u = $null if (-not [uri]::TryCreate($src, [System.UriKind]::Absolute, [ref]$u)) { throw "The update source is not a valid address: $src" } if ($u.Scheme -eq "http" -and -not $u.IsLoopback) { throw "Plain http is refused for update sources (only https, or http on localhost): $src" } if ($u.Scheme -eq "http" -and $u.UserInfo) { throw "Plain http is refused for update sources (only https, or http on localhost): $src" } if ($u.UserInfo) { throw "An update source with a user name or password in the address is refused: $src" } } function Get-BuildStamp { param($Value) # Windows PowerShell 5.1 turns an ISO date in JSON into a DateTime; PowerShell 7 leaves a string. Normalise both. if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") } return ([datetimeoffset]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ") } # Antivirus and indexers hold freshly written files for a moment: file operations get a few quick retries. function Invoke-WithRetry { param([scriptblock]$Action, [int]$Attempts = 5, [int]$DelayMs = 200) for ($i = 1; ; $i++) { try { & $Action; return } catch { if ($i -ge $Attempts) { throw } Start-Sleep -Milliseconds $DelayMs } } } # Processes of this install. $Names limits which programs count. function Get-RunningFromInstall { param([string[]]$Names = @("MiniPad", "MiniPad.Runner", "MiniPad.Mcp", "MiniPad.Cli")) $prefix = $root + "\" return @(Get-Process -Name $Names -ErrorAction SilentlyContinue | Where-Object { $p = $null try { $p = $_.Path } catch { } $p -and $p.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) }) } # Never kills anything: the user may have unsaved work. Waits, then gives up with a clear message. An update only waits for the # editor (MiniPad.exe): an AI client keeps MiniPad.Mcp.exe and MiniPad.Runner.exe alive, and the swap does not need their files. # Uninstall deletes the whole folder, so it waits for all of them. function Wait-ForMiniPadToClose { param([switch]$All) $names = @("MiniPad") if ($All) { $names = @("MiniPad", "MiniPad.Runner", "MiniPad.Mcp", "MiniPad.Cli") } $deadline = (Get-Date).AddSeconds($WaitSeconds) $told = $false while ($true) { $running = Get-RunningFromInstall -Names $names if ($running.Count -eq 0) { return } $list = ($running | ForEach-Object { $_.ProcessName } | Sort-Object -Unique) -join ", " $hint = "" if ($All -and -not ($running | Where-Object { $_.ProcessName -eq "MiniPad" })) { $hint = " Only helper programs are running; an AI client usually keeps MiniPad.Mcp.exe alive: close your AI client (Claude Code, Claude Desktop, ...)." } if ((Get-Date) -ge $deadline) { throw "MiniPad is still running from '$root' ($list). Close MiniPad, then run this command again.$hint Nothing was changed." } if (-not $told) { Write-Host " MiniPad is running ($list). Please close MiniPad; waiting up to $WaitSeconds seconds ...$hint" $told = $true } Start-Sleep -Seconds 2 } } # A junction is removed with Directory.Delete (not recursive), which never follows it into the target. No cmd: a '%NAME%' in a # path must stay a literal. function Remove-Junction { param([string]$Path) if (Test-Path -LiteralPath $Path) { Invoke-WithRetry { [System.IO.Directory]::Delete($Path) } } if (Test-Path -LiteralPath $Path) { throw "Could not remove the link $Path." } } function New-Junction { param([string]$Link, [string]$Target) [void](New-Item -ItemType Junction -Path $Link -Target $Target) if (-not (Test-Path -LiteralPath $Link)) { throw "Could not create the link $Link -> $Target." } } # A folder may only be deleted if NOTHING in it is in use. An AI client registers .\current\MiniPad.Mcp.exe, so the running program # shows up under the junction's path and cannot be matched to a version by name; asking the files themselves is exact: a running # exe or a loaded dll cannot be opened for writing, whatever path it was started from. function Test-FolderFree { param([string]$Path) foreach ($f in @(Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in ".exe", ".dll" })) { $stream = $null try { $stream = [System.IO.File]::Open($f.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) } catch { return $false } finally { if ($stream) { $stream.Dispose() } } } return $true } # Deletes the folder only when it is wholly free (never a part of it). Returns false when it was left alone. function Remove-FolderIfFree { param([string]$Path) if (-not (Test-FolderFree $Path)) { return $false } Invoke-WithRetry { Remove-Item -LiteralPath $Path -Recurse -Force } return $true } function Start-MiniPad { param([string]$Exe) try { Start-Process -FilePath $Exe } catch { Write-Warning "MiniPad was installed, but could not be started: $($_.Exception.Message). Start it from the Start menu." } } function Get-FileSha256 { param([string]$Path) return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } # .lnk files through IShellLinkW (Unicode). WScript.Shell goes through the ANSI code page and fails for a profile path with non-ANSI characters in it. function Initialize-ShellLink { if ("MiniPadSetup.ShellLink" -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; namespace MiniPadSetup { [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink { } [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")] interface IShellLinkW { void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, IntPtr pfd, int fFlags); void GetIDList(out IntPtr ppidl); void SetIDList(IntPtr pidl); void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch); void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch); void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch); void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); void GetHotkey(out short pwHotkey); void SetHotkey(short wHotkey); void GetShowCmd(out int piShowCmd); void SetShowCmd(int iShowCmd); void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cch, out int piIcon); void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved); void Resolve(IntPtr hwnd, int fFlags); void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); } public static class ShellLink { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] static extern int SHParseDisplayName(string name, IntPtr bindingContext, out IntPtr pidl, uint sfgaoIn, out uint sfgaoOut); [DllImport("shell32.dll")] static extern void ILFree(IntPtr pidl); public static void Create(string link, string target, string workingDir, string description) { IShellLinkW shell = (IShellLinkW)new CShellLink(); // SetPath expands %NAME% in the text, so a folder called %TEMP%x would point somewhere else. A parsed ITEMIDLIST is a // real item and is never expanded. IntPtr pidl; uint attributes; if (SHParseDisplayName(target, IntPtr.Zero, out pidl, 0, out attributes) == 0 && pidl != IntPtr.Zero) { try { shell.SetIDList(pidl); } finally { ILFree(pidl); } } else { shell.SetPath(target); } // The working directory is stored as text and expanded when the link is used: leave it out for such a path. if (workingDir.IndexOf('%') < 0) { shell.SetWorkingDirectory(workingDir); } shell.SetDescription(description); ((IPersistFile)shell).Save(link, true); } public static string GetTarget(string link) { IShellLinkW shell = (IShellLinkW)new CShellLink(); ((IPersistFile)shell).Load(link, 0); StringBuilder path = new StringBuilder(1024); shell.GetPath(path, path.Capacity, IntPtr.Zero, 0); return path.ToString(); } } } '@ } function New-MiniPadShortcut { Initialize-ShellLink New-Item -ItemType Directory -Path $shortcuts -Force | Out-Null [MiniPadSetup.ShellLink]::Create($linkPath, (Join-Path $currentDir "MiniPad.exe"), $currentDir, "MiniPad - C# scratchpad") } # ---- Uninstall --------------------------------------------------------------------------------------------------------- if ($Uninstall) { Write-Host "MiniPad uninstall" Write-Host " install folder: $root" if (-not (Test-Path -LiteralPath $root)) { Write-Host " Nothing to remove."; return } # Only ever remove something that looks like a MiniPad install: -InstallRoot is a plain path, and a typo must not wipe a folder. # It needs the 'versions' folder, 'current' as a junction, AND the marker file that this installer writes. $looksRight = (Test-Path -LiteralPath $versionsDir) -and (Test-Path -LiteralPath $markerPath) -and (Test-Path -LiteralPath $currentDir) if ($looksRight) { $looksRight = [bool]((Get-Item -LiteralPath $currentDir -Force).Attributes -band [System.IO.FileAttributes]::ReparsePoint) } if (-not $looksRight) { throw "'$root' does not look like a MiniPad install (it needs 'versions', a 'current' link and the .minipad-install marker that install.ps1 writes). Nothing was removed." } Wait-ForMiniPadToClose -All $exe = Join-Path $currentDir "MiniPad.exe" if ((Test-Path -LiteralPath (Join-Path $root "filetypes.txt")) -and (Test-Path -LiteralPath $exe)) { & $exe --unregister-file-types | Out-Null Write-Host " removed the Open with registrations" } $changed = Update-UserPath -Remove @($shimDir, (Join-Path $currentDir "cli")) -SubKey $PathSubKey -NoBroadcast:$NoBroadcast if ($changed) { Write-Host " removed MiniPad from your PATH (the rest of PATH is exactly as it was)" } if (Test-Path -LiteralPath $linkPath) { $target = $null try { Initialize-ShellLink; $target = [MiniPadSetup.ShellLink]::GetTarget($linkPath) } catch { } if ($target -and $target.StartsWith($root + "\", [System.StringComparison]::OrdinalIgnoreCase)) { Remove-Item -LiteralPath $linkPath -Force Write-Host " removed the Start menu shortcut" } } Remove-Junction $currentDir Remove-Junction (Join-Path $root "current.new") Remove-Junction (Join-Path $root "current.old") Invoke-WithRetry { Remove-Item -LiteralPath $root -Recurse -Force } Write-Host " removed $root" Write-Host "Your scripts, history and settings in $(Join-Path $env:USERPROFILE '.minipad') were not touched." -ForegroundColor Green return } # ---- Install / update -------------------------------------------------------------------------------------------------- Write-Host "MiniPad installer" Write-Host " install folder: $root" Write-Host " source: $src" # 1. The .NET 10 SDK. MiniPad compiles scripts against it, so the runtime alone is not enough. Never installed silently. if (-not $SkipSdkCheck) { $hasSdk = $false if (Get-Command dotnet -ErrorAction SilentlyContinue) { # Through cmd so native stderr never becomes a terminating error under Windows PowerShell 5.1. foreach ($line in @(cmd /c "dotnet --list-sdks 2>nul")) { if ($line -match "^10\.") { $hasSdk = $true } } } if (-not $hasSdk) { Write-Host "" Write-Warning "The .NET 10 SDK was not found. MiniPad needs it to compile your scripts." Write-Host " Download it from: https://dotnet.microsoft.com/download/dotnet/10.0" Write-Host " or run: winget install Microsoft.DotNet.SDK.10" Write-Host " Then run this command again. Nothing was installed." throw "The .NET 10 SDK is required." } } # 2. version.json, read ONCE: the same text is what gets saved into the install. function Read-SourceText { param([string]$Name) if ($isWeb) { return (Invoke-WebRequest -Uri "$src/$Name" -UseBasicParsing).Content } return [System.IO.File]::ReadAllText((Join-Path $src $Name)) } try { $manifestText = Read-SourceText "version.json"; $manifest = $manifestText | ConvertFrom-Json } catch { throw "Could not read version.json from $src : $($_.Exception.Message)" } if (-not $manifest.version -or -not $manifest.buildTimeUtc -or -not $manifest.parts) { throw "version.json from $src is incomplete." } foreach ($part in @($manifest.parts)) { if ([string]$part.name -cnotmatch '^MiniPad\.zip\.\d{3}$') { throw "version.json lists an unexpected file name '$($part.name)'. Not installing." } } $remoteStamp = Get-BuildStamp $manifest.buildTimeUtc $stampName = $remoteStamp -replace "[-:]", "" Write-Host " available: MiniPad $($manifest.version), built $remoteStamp" # Leftovers of an earlier run that was interrupted. if (Test-Path -LiteralPath $versionsDir) { foreach ($stale in @(Get-ChildItem -LiteralPath $versionsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like ".staging-*" -or $_.Name -like ".old-*" })) { try { [void](Remove-FolderIfFree $stale.FullName) } catch { } } } # 3. Already installed? $installedManifest = Join-Path $currentDir "version.json" if ((Test-Path -LiteralPath $installedManifest) -and -not $Force) { try { $installedStamp = Get-BuildStamp ((Get-Content -LiteralPath $installedManifest -Raw) | ConvertFrom-Json).buildTimeUtc if ($installedStamp -eq $remoteStamp) { Write-Host " MiniPad is already up to date (built $installedStamp). Use -Force to reinstall." -ForegroundColor Green # A rerun repairs what is missing around the files. if (-not $NoShortcut -and -not (Test-Path -LiteralPath $linkPath)) { try { New-MiniPadShortcut; Write-Host " Start menu shortcut created." } catch { Write-Warning "Could not create the Start menu shortcut: $($_.Exception.Message)" } } if ($Launch) { Start-MiniPad (Join-Path $currentDir "MiniPad.exe") } return } } catch { Write-Host " (the installed version.json could not be read; installing)" } } # 4. Download (or copy), verify every part, join, verify the whole. $temp = Join-Path ([System.IO.Path]::GetTempPath()) ("MiniPad-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $temp -Force | Out-Null $staging = $null try { $partFiles = @() foreach ($part in @($manifest.parts)) { $file = Join-Path $temp $part.name Write-Host " fetching $($part.name) ..." if ($isWeb) { Invoke-WebRequest -Uri "$src/$($part.name)" -OutFile $file -UseBasicParsing } else { Copy-Item -LiteralPath (Join-Path $src $part.name) -Destination $file } if ((Get-Item -LiteralPath $file).Length -ne [long]$part.bytes) { throw "$($part.name) has the wrong size. Not installing." } if ((Get-FileSha256 $file) -ne ([string]$part.sha256).ToLowerInvariant()) { throw "$($part.name) failed its SHA-256 check. Not installing." } $partFiles += $file } $zip = Join-Path $temp "MiniPad.zip" $out = [System.IO.File]::Open($zip, [System.IO.FileMode]::Create) try { foreach ($file in $partFiles) { $in = [System.IO.File]::OpenRead($file) try { $in.CopyTo($out) } finally { $in.Dispose() } } } finally { $out.Dispose() } if ((Get-FileSha256 $zip) -ne ([string]$manifest.zipSha256).ToLowerInvariant()) { throw "The joined MiniPad.zip failed its SHA-256 check. Not installing." } Write-Host " checksums ok" # 5. Nothing may run as MiniPad.exe while the folders are swapped (MiniPad.Mcp/Runner may keep running: see above). Wait-ForMiniPadToClose # 6. Extract next to the other versions, then move into place. A rebuild of the SAME build is put in place next to the # live copy first and only then swapped, so the live copy is never deleted before the new one exists. New-Item -ItemType Directory -Path $versionsDir -Force | Out-Null $target = Join-Path $versionsDir $stampName if ((Test-Path -LiteralPath $target) -and -not (Test-FolderFree $target)) { throw "The installed copy of this build is in use (a program such as MiniPad.Mcp.exe is running from it), so it cannot be replaced. Close it (an AI client keeps MiniPad.Mcp.exe alive), then run this command again. Nothing was changed." } $staging = Join-Path $versionsDir (".staging-" + [guid]::NewGuid().ToString("N")) Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging) if (-not (Test-Path -LiteralPath (Join-Path $staging "MiniPad.exe"))) { throw "The payload has no MiniPad.exe." } [System.IO.File]::WriteAllText((Join-Path $staging "version.json"), $manifestText) Get-ChildItem -LiteralPath $staging -Recurse -File | Unblock-File if (Test-Path -LiteralPath $target) { $aside = Join-Path $versionsDir (".old-" + [guid]::NewGuid().ToString("N")) Invoke-WithRetry { [System.IO.Directory]::Move($target, $aside) } try { Invoke-WithRetry { [System.IO.Directory]::Move($staging, $target) } } catch { [System.IO.Directory]::Move($aside, $target); throw } try { if (-not (Remove-FolderIfFree $aside)) { Write-Warning "The replaced copy is still in use and was left in place: it is cleaned up on a later run." } } catch { Write-Warning "Could not remove the replaced copy: it is cleaned up on a later run." } } else { Invoke-WithRetry { [System.IO.Directory]::Move($staging, $target) } } $staging = $null Write-Host " extracted to $target" } finally { if ($staging -and (Test-Path -LiteralPath $staging)) { try { Remove-Item -LiteralPath $staging -Recurse -Force } catch { } } if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue } } # 7. Repoint 'current': a junction next to it first, then two renames (the old target stays in versions\ for rollback). $previousTarget = $null if (Test-Path -LiteralPath $currentDir) { try { $previousTarget = (Get-Item -LiteralPath $currentDir).Target | Select-Object -First 1 } catch { } } $newLink = Join-Path $root "current.new" $oldLink = Join-Path $root "current.old" Remove-Junction $newLink Remove-Junction $oldLink New-Junction -Link $newLink -Target $target if (Test-Path -LiteralPath $currentDir) { Invoke-WithRetry { [System.IO.Directory]::Move($currentDir, $oldLink) } } try { if ($FailSwapForTest) { throw "swap failed (test)" } Invoke-WithRetry { [System.IO.Directory]::Move($newLink, $currentDir) } } catch { # Put the working link back: an interrupted swap must never leave MiniPad without a 'current'. if ((Test-Path -LiteralPath $oldLink) -and -not (Test-Path -LiteralPath $currentDir)) { [System.IO.Directory]::Move($oldLink, $currentDir) } try { Remove-Junction $newLink } catch { } throw } Remove-Junction $oldLink Set-Content -LiteralPath $markerPath -Value "MiniPad install folder, written by install.ps1. -Uninstall only removes folders that have this file." -Encoding Ascii # 8. Keep the last 2 versions (never the new one). A folder is only deleted when none of its programs or dlls is in use: a # MiniPad.Mcp.exe or MiniPad.Runner.exe that an AI client started (even through the 'current' link) keeps its files locked. $running = Get-RunningFromInstall $keep = @(Get-ChildItem -LiteralPath $versionsDir -Directory | Where-Object { $_.Name -notlike ".*" } | Sort-Object Name -Descending | Select-Object -First 2 | ForEach-Object { $_.Name }) foreach ($dir in @(Get-ChildItem -LiteralPath $versionsDir -Directory | Where-Object { $_.Name -notlike ".*" })) { if ($keep -contains $dir.Name -or $dir.Name -eq $stampName) { continue } try { if (Remove-FolderIfFree $dir.FullName) { Write-Host " removed the old version $($dir.Name)" } else { Write-Host " kept the old version $($dir.Name): a program is still running from it" } } catch { Write-Warning "Could not remove the old version $($dir.Name): $($_.Exception.Message)" } } # 9. Start menu shortcut (it points through 'current', so it needs no change on the next update). A failure here is a warning: # the new version is already in place, and a rerun creates a missing shortcut. $exe = Join-Path $currentDir "MiniPad.exe" if (-not $NoShortcut) { try { New-MiniPadShortcut; Write-Host " Start menu shortcut created." } catch { Write-Warning "Could not create the Start menu shortcut: $($_.Exception.Message). Run this command again to retry." } } # 10. Optional: the 'minipad' command on PATH, edited byte for byte. if ($AddToPath) { if (-not (Test-Path -LiteralPath (Join-Path $shimDir "minipad.cmd"))) { throw "The payload has no shim\minipad.cmd." } $changed = Update-UserPath -Add @($shimDir) -SubKey $PathSubKey -NoBroadcast:$NoBroadcast if ($changed) { Write-Host " added $shimDir to your PATH. Open a new terminal to use 'minipad'." } else { Write-Host " $shimDir is already on your PATH." } } if ($AssociateFiles) { & $exe --register-file-types | Out-Null Set-Content -LiteralPath (Join-Path $root "filetypes.txt") -Value "1" -Encoding Ascii Write-Host " MiniPad is now offered in Open with for .linq and .csx." } Write-Host "" Write-Host "MiniPad $($manifest.version) (built $remoteStamp) is installed. Start it from the Start menu, or run $exe" -ForegroundColor Green if ($running.Count -gt 0) { Write-Host " MiniPad.Mcp.exe or MiniPad.Runner.exe was still running. Restart your AI client (Claude Code, Claude Desktop, ...) to pick up the new MCP server." -ForegroundColor Yellow } if ($Launch) { Start-MiniPad $exe } } Invoke-MiniPadInstall }