# FlintTrade Electron shell uninstaller (Windows) # # Ordinary uninstall removes the electron-builder application, its shortcuts, # the retired pre-Electron shell at %LOCALAPPDATA%\FlintTrade (the same legacy # install flinttrade-install.ps1 retires on upgrade) and the one-line web # install's launcher shim and Start Menu shortcut, each only when the # installer's own receipt proves it. # The workspace, Electron profile, managed source/toolchain, the contributor # source-build checkout, the pre-workspace data directories and legacy desktop # storage and the stable installation identity/migration tombstones are kept # unless -Purge is explicitly confirmed. The platform-default lineage root is # removed last and only after all earlier data targets were removed. Ambient # state-root overrides are never recursive-deletion authority. # Destructive purge is currently refused until a verified protection-preserving # maintenance controller is available; -DryRun still lists the proposed scope. param( [switch]$Purge, [switch]$Yes, [switch]$DryRun = ($env:FLINTTRADE_UNINSTALL_DRY_RUN -eq "1") ) $ErrorActionPreference = "Stop" $LegacyBundleId = "com.flinttrade.app" $LocalAppDataRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) $RoamingAppDataRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::ApplicationData) $DefaultInstallDir = Join-Path $LocalAppDataRoot "Programs\FlintTrade" # The retired pre-Electron shell installed here with an NSIS "uninstall.exe". # flinttrade-install.ps1 retires it on upgrade; the uninstaller had no # counterpart, so a whole second FlintTrade stayed behind (and its registry # entry made every uninstall fail closed). $LegacyShellInstallDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "FlintTrade" } else { "" } $DefaultWorkspace = Join-Path $RoamingAppDataRoot "flinttrade" $DefaultInstallationState = Join-Path $RoamingAppDataRoot "flinttrade-installation" $ElectronProfile = Join-Path $RoamingAppDataRoot "flinttrade-shell" $ManagedRoot = Join-Path $HOME ".flinttrade" $SourceRoot = Join-Path $ManagedRoot "src" $ToolsRoot = Join-Path $ManagedRoot "tools" # The same installer family clones the contributor checkout here # (flinttrade-install.ps1 -SrcDir defaults to $SourceBuildRoot\FlintTrade; # FLINTTRADE_SRC_DIR overrides it). The Windows workspace lives under %APPDATA%, # so nothing else in the purge list would ever reach this tree. $SourceBuildRoot = Join-Path $ManagedRoot "source-build" # Pre-workspace data directories. workspace.py still reads these at every # backend start, and its migration COPIES rather than moves — so they retain a # live copy of the DuckDB store, the append-only audit chain and the encrypted # broker-credential vault. Purging them is real data loss for an upgraded # install, which is why every path is printed before any confirmation. $LegacyDataDir = Join-Path $ManagedRoot "data" $LegacyArchiveDir = Join-Path $ManagedRoot "archive" $LegacySandboxDir = Join-Path $ManagedRoot "sandbox" $LegacyDittoVault = Join-Path $LegacyDataDir "ditto_credentials.db" # Pre-workspace droppings written DIRECTLY at .flinttrade\ by the modules the # workspace path-unification wave re-pointed at the resolver. Windows is the worst # case: the real workspace is %APPDATA%\flinttrade, so everything here is a full # second copy of state the backend now reads from somewhere else - the TOTP secret # store and its install key, the trade journal and its screenshots, and the # operator's own flows, models and strategy files. # # $ManagedRoot below would delete them all transitively, but only by never naming # them. Enumerating each one is what makes the confirmation list honest: nobody # should confirm an irreversible purge of their own strategy code from a list that # does not mention it. $LegacyFlowsDir = Join-Path $ManagedRoot "flows" $LegacyModelsDir = Join-Path $ManagedRoot "models" $LegacyStrategiesDir = Join-Path $ManagedRoot "strategies" $LegacyScreenshotsDir = Join-Path $ManagedRoot "journal_screenshots" $LegacyTotpDb = Join-Path $ManagedRoot "totp_auth.duckdb" $LegacyTotpInstallKey = Join-Path $ManagedRoot "totp_install_key" $LegacyJournalDb = Join-Path $ManagedRoot "journal.sqlite" $LegacyShortcutsDb = Join-Path $ManagedRoot "shortcuts.duckdb" $LegacyQtyFreezeDb = Join-Path $ManagedRoot "qty_freeze.duckdb" $LegacyActionCenterDb = Join-Path $ManagedRoot "action_center.duckdb" $LegacyWatchlistDb = Join-Path $ManagedRoot "watchlist.db" $LegacyPresetsFile = Join-Path $ManagedRoot "presets.json" $LegacyLatencyDb = Join-Path $ManagedRoot "latency_log.duckdb" $LegacyTrafficDb = Join-Path $ManagedRoot "traffic_log.duckdb" # The one-line web installer records everything it writes outside the managed # root here (flinttrade-web-install.ps1). Without it the launcher shim and its # Start Menu shortcut were orphaned residue. The web launcher now lives in its # own $WebLauncherDir and its own Start Menu folder, precisely so it can never be # confused with — or block — the Electron shell at $DefaultInstallDir; earlier # revisions put it inside $DefaultInstallDir, so with no Electron registry record # every uninstall reported an unproven same-name install directory and failed # closed. $WebLauncherDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "Programs\FlintTradeWeb" } else { "" } $WebReceiptDir = if ($LocalAppDataRoot) { Join-Path $LocalAppDataRoot "flinttrade-web" } else { "" } $WebReceiptPath = if ($WebReceiptDir) { Join-Path $WebReceiptDir "web-install.receipt" } else { "" } $script:RemovedAny = $false $script:FailedAny = $false $script:PurgeCompleted = $false $script:PurgedDataAny = $false $script:LineageOverlapFound = $false $script:DataRetainedAny = $false $script:LegacyShellRecord = $null $script:WebReceipt = $null $script:WebReceiptRetained = $false $script:WebRemovedAny = $false $script:WebShimProven = $false $script:ShellRemovedAny = $false function Say([string]$Message) { Write-Host "[flinttrade] $Message" -ForegroundColor Cyan } function Warn([string]$Message) { Write-Host "[flinttrade] $Message" -ForegroundColor Yellow } function Expand-FlintPath([string]$Value) { if ($Value -eq "~") { return $HOME } if ($Value.StartsWith("~\") -or $Value.StartsWith("~/")) { return Join-Path $HOME $Value.Substring(2) } if ($Value.StartsWith("~")) { throw "Named-user home paths are not supported: $Value" } return [Environment]::ExpandEnvironmentVariables($Value) } $WorkspaceDir = if ($env:FLINTTRADE_WORKSPACE_DIR) { Expand-FlintPath $env:FLINTTRADE_WORKSPACE_DIR } elseif ($env:FLINTTRADE_HOME) { Expand-FlintPath $env:FLINTTRADE_HOME } else { $DefaultWorkspace } $InstallationStateRoot = $DefaultInstallationState # One installed helper owns the complete transaction and exclusion. Never # continue into a PowerShell deletion loop, even after a zero helper exit. if ($Purge -and -not $DryRun) { if (-not $Yes) { Warn "Purge refused: explicit -Purge -Yes confirmation required; data and receipts retained." exit 1 } foreach ($candidate in @( (Join-Path $SourceRoot "FlintTrade\.venv\Scripts\python.exe"), (Join-Path $SourceBuildRoot "FlintTrade\.venv\Scripts\python.exe") )) { if (Test-Path -LiteralPath $candidate -PathType Leaf) { & $candidate -m flinttrade_core.workspace_maintenance purge --workspace $WorkspaceDir --yes exit $LASTEXITCODE } } Warn "Purge refused: installed maintenance helper and safe policy unavailable; all data, shell and receipts retained." exit 1 } # A relative override otherwise resolves against the uninstaller's own working # directory at every later use, so the path that gets printed is not necessarily # the path that would be deleted. Resolve it once, up front. function Resolve-AbsoluteFlintPath([string]$Value) { if (-not $Value) { return "" } try { return [System.IO.Path]::GetFullPath($Value).TrimEnd('\', '/') } catch { return "" } } $SrcDirOverride = if ($env:FLINTTRADE_SRC_DIR) { Resolve-AbsoluteFlintPath (Expand-FlintPath $env:FLINTTRADE_SRC_DIR) } else { "" } function Remove-IfExists([string]$Target) { if (-not (Test-Path -LiteralPath $Target)) { return } if ($DryRun) { Say "[dry-run] would remove $Target"; return } try { Remove-Item -LiteralPath $Target -Recurse -Force -ErrorAction Stop Say "Removed $Target" $script:RemovedAny = $true } catch { Warn "Could not remove ${Target}: $($_.Exception.Message)" $script:FailedAny = $true } } function Test-PathContainsReparsePoint([string]$Target) { try { $full = [System.IO.Path]::GetFullPath($Target).TrimEnd('\', '/') $root = [System.IO.Path]::GetPathRoot($full) if (-not $root) { return $true } $relative = $full.Substring($root.Length).TrimStart('\', '/') $current = $root foreach ($component in @($relative -split '[\\/]' | Where-Object { $_ })) { $current = Join-Path $current $component if (-not (Test-Path -LiteralPath $current)) { continue } $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true } } } catch { return $true } return $false } function Test-CurrentUserOnlyAcl( [string]$Path, [System.Security.AccessControl.InheritanceFlags]$ExpectedInheritance, [bool]$RequireProtected, [bool]$RequireExplicit ) { try { $acl = Get-Acl -LiteralPath $Path $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) if (-not $owner -or $owner.Value -ne $identity.User.Value) { return $false } if ($RequireProtected -and -not $acl.AreAccessRulesProtected) { return $false } $rules = @($acl.Access) if ($rules.Count -ne 1) { return $false } $ruleIdentity = $rules[0].IdentityReference.Translate( [System.Security.Principal.SecurityIdentifier]) if ($RequireExplicit -and $rules[0].IsInherited) { return $false } return ( $ruleIdentity.Value -eq $identity.User.Value -and $rules[0].FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and $rules[0].InheritanceFlags -eq $ExpectedInheritance -and $rules[0].PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None -and $rules[0].AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow ) } catch { return $false } } function Test-TrustedWebReceiptAcl { $directoryInheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit return ( (Test-CurrentUserOnlyAcl $WebReceiptDir $directoryInheritance $true $true) -and (Test-CurrentUserOnlyAcl $WebReceiptPath ` ([System.Security.AccessControl.InheritanceFlags]::None) $false $false) ) } function Read-WebInstallReceipt { # Returns the recorded web-install identity, or $null when no web install # was ever recorded or the receipt does not prove itself. A missing receipt # is never an uninstall failure: the machine simply has no web install. if (-not $WebReceiptPath) { return $null } if (-not (Test-Path -LiteralPath $WebReceiptPath -PathType Leaf)) { return $null } if (Test-PathContainsReparsePoint $WebReceiptPath) { Warn "Leaving $WebReceiptPath because its path contains a reparse alias." return $null } if (-not (Test-TrustedWebReceiptAcl)) { Warn "Leaving $WebReceiptPath because it is not owner-private for the current user." return $null } try { $lines = @(Get-Content -LiteralPath $WebReceiptPath -Encoding UTF8) } catch { Warn "Leaving $WebReceiptPath because it could not be read: $($_.Exception.Message)" return $null } if ($lines.Count -ne 7) { Warn "Leaving $WebReceiptPath because the receipt shape is invalid." return $null } if ($lines[0] -cne "format=flinttrade-web-install-v1" -or $lines[1] -cne "platform=Windows") { Warn "Leaving $WebReceiptPath because the receipt format or platform does not match." return $null } $prefixes = @("shim=", "shim_sha256=", "shortcut=", "source=", "tools=") for ($index = 0; $index -lt $prefixes.Count; $index++) { if (-not ([string]$lines[$index + 2]).StartsWith($prefixes[$index], [StringComparison]::Ordinal)) { Warn "Leaving $WebReceiptPath because the receipt field names are invalid." return $null } } $shim = ([string]$lines[2]).Substring($prefixes[0].Length) $shimSha256 = ([string]$lines[3]).Substring($prefixes[1].Length).ToLowerInvariant() $shortcut = ([string]$lines[4]).Substring($prefixes[2].Length) $source = ([string]$lines[5]).Substring($prefixes[3].Length) $tools = ([string]$lines[6]).Substring($prefixes[4].Length) if (-not $shim -or $shimSha256 -notmatch '^[0-9a-f]{64}$') { Warn "Leaving $WebReceiptPath because the receipt omits exact launcher identity." return $null } # The receipt may only ever aim the remover at a location the web installer # writes to; it is not a general deletion instruction. Both the current pair # and the pre-collision one are accepted: revisions before # %LOCALAPPDATA%\Programs\FlintTradeWeb wrote into the Electron shell's own # install directory and Start Menu folder, and those machines still deserve a # clean uninstall. Identity is never assumed from the path — the launcher is # removed only when its SHA-256 still matches the receipt, and the shortcut # only when it still points at that launcher. $expectedShims = @( (Resolve-AbsoluteFlintPath (Join-Path $WebLauncherDir "flinttrade-web.cmd")), (Resolve-AbsoluteFlintPath (Join-Path $DefaultInstallDir "flinttrade.cmd")) ) $expectedShortcuts = @( (Resolve-AbsoluteFlintPath ( Join-Path $RoamingAppDataRoot "Microsoft\Windows\Start Menu\Programs\FlintTrade Web\FlintTrade Web.lnk")), (Resolve-AbsoluteFlintPath ( Join-Path $RoamingAppDataRoot "Microsoft\Windows\Start Menu\Programs\FlintTrade\FlintTrade.lnk")) ) $resolvedShim = Resolve-AbsoluteFlintPath $shim if (-not @($expectedShims | Where-Object { $_.Equals($resolvedShim, [StringComparison]::OrdinalIgnoreCase) })) { Warn "Leaving $WebReceiptPath because the recorded launcher is not the installer-owned location." return $null } if ($shortcut) { $resolvedShortcut = Resolve-AbsoluteFlintPath $shortcut if (-not @($expectedShortcuts | Where-Object { $_.Equals($resolvedShortcut, [StringComparison]::OrdinalIgnoreCase) })) { Warn "Leaving $WebReceiptPath because the recorded shortcut is not the installer-owned location." return $null } } [pscustomobject]@{ Shim = (Resolve-AbsoluteFlintPath $shim) ShimSha256 = $shimSha256 Shortcut = (Resolve-AbsoluteFlintPath $shortcut) Source = (Resolve-AbsoluteFlintPath $source) Tools = (Resolve-AbsoluteFlintPath $tools) } } function Remove-EmptyOwnedDirectory([string]$Path) { if (-not $Path) { return } if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return } if (Test-PathContainsReparsePoint $Path) { return } $children = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue) if ($children.Count -ne 0) { return } if ($DryRun) { Say "[dry-run] would remove empty directory $Path" return } try { Remove-Item -LiteralPath $Path -Force -ErrorAction Stop Say "Removed $Path" $script:RemovedAny = $true } catch { Warn "Could not remove empty directory ${Path}: $($_.Exception.Message)" } } function Remove-ProvenWebInstall { $receipt = Read-WebInstallReceipt if (-not $receipt) { return } $script:WebReceipt = $receipt if (Test-Path -LiteralPath $receipt.Shim) { if (Test-PathContainsReparsePoint $receipt.Shim) { Warn "Leaving $($receipt.Shim) because its path contains a reparse alias." return } $actual = "" try { $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $receipt.Shim).Hash.ToLowerInvariant() } catch { $actual = "" } if (-not $actual -or $actual -ne $receipt.ShimSha256) { Warn "Leaving $($receipt.Shim) because its SHA-256 identity does not match the web-install receipt." Warn "Keeping $WebReceiptPath so a later run can retry." return } $script:WebShimProven = $true Remove-IfExists $receipt.Shim if (-not $DryRun -and (Test-Path -LiteralPath $receipt.Shim)) { Warn "Keeping $WebReceiptPath because the recorded launcher could not be removed." return } if (-not $DryRun) { Say "Removed the launcher recorded by $WebReceiptPath." $script:WebRemovedAny = $true } } if ($receipt.Shortcut -and (Test-Path -LiteralPath $receipt.Shortcut)) { $target = Get-ShortcutTarget $receipt.Shortcut $proven = $target -and ([string]$receipt.Shim).Equals([string]$target, [StringComparison]::OrdinalIgnoreCase) -and -not (Test-PathContainsReparsePoint $receipt.Shortcut) if ($proven) { Remove-IfExists $receipt.Shortcut } else { Warn "Leaving $($receipt.Shortcut) because it no longer points at the recorded launcher." } } Remove-EmptyOwnedDirectory (Split-Path -Parent $receipt.Shim) if ($receipt.Shortcut) { Remove-EmptyOwnedDirectory (Split-Path -Parent $receipt.Shortcut) } # An ordinary uninstall deliberately RETAINS the managed source and tools the # receipt records - and for a custom -SrcDir outside the managed root that # receipt is the only thing that names them. Deleting it here left a later # -Purge with no proof and no path, so the custom checkout was omitted # permanently. Keep the receipt for exactly as long as it still proves # retained data. if (Test-WebInstallDataStillPresent) { $script:WebReceiptRetained = $true Say "Keeping $WebReceiptPath - it is the only proof of the retained web-install data below," Say "so a later -Purge can still find and authorise it:" foreach ($retained in @([string]$receipt.Source, [string]$receipt.Tools)) { if ($retained) { Say " $retained" } } return } Remove-IfExists $WebReceiptPath Remove-EmptyOwnedDirectory $WebReceiptDir } # Whether anything the web-install receipt records as retained data is still on # disk. The launcher is not retained data - it is removed by the ordinary # uninstall - so only the managed source and tools count here. function Test-WebInstallDataStillPresent { if (-not $script:WebReceipt) { return $false } foreach ($candidate in @([string]$script:WebReceipt.Source, [string]$script:WebReceipt.Tools)) { if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $true } } return $false } # The retained receipt is retired once it no longer proves anything: a -Purge in # this same run has just removed the recorded source and tools, so the receipt is # removed exactly as an ordinary uninstall would have removed it. function Remove-RetainedWebReceipt { if (-not $script:WebReceiptRetained) { return } if ($DryRun) { return } if (Test-WebInstallDataStillPresent) { return } Remove-IfExists $WebReceiptPath Remove-EmptyOwnedDirectory $WebReceiptDir } function Get-InstallEntries { @(Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue) | Where-Object { $_.DisplayName -ceq "FlintTrade" } } function Get-CommandExecutable([string]$CommandLine) { if (-not $CommandLine) { return $null } $trimmed = $CommandLine.Trim() if ($trimmed -match '^"([^"]+)"') { return $Matches[1] } return ($trimmed -split '\s+', 2)[0] } function Test-AllowedInstallDirectory([string]$Candidate) { try { $full = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') $allowed = [System.IO.Path]::GetFullPath($DefaultInstallDir).TrimEnd('\', '/') } catch { return $false } return $full.Equals($allowed, [StringComparison]::OrdinalIgnoreCase) } function Get-ProvenInstallRecord($Entry) { if (-not $Entry.PSPath -or -not $Entry.UninstallString) { return $null } $registeredCommand = Get-CommandExecutable ([string]$Entry.UninstallString) if (-not $registeredCommand) { return $null } try { $registeredCommand = [Environment]::ExpandEnvironmentVariables($registeredCommand) $registeredFull = [System.IO.Path]::GetFullPath($registeredCommand).TrimEnd('\', '/') $directoryFull = [System.IO.Path]::GetFullPath((Split-Path -Parent $registeredFull)).TrimEnd('\', '/') } catch { return $null } if ((Split-Path -Leaf $registeredFull) -cne "Uninstall FlintTrade.exe") { return $null } if (-not (Test-AllowedInstallDirectory $directoryFull)) { return $null } if (-not (Test-Path -LiteralPath $directoryFull -PathType Container)) { return $null } if (Test-PathContainsReparsePoint $directoryFull) { return $null } try { $directory = Get-Item -LiteralPath $directoryFull -Force -ErrorAction Stop $canonicalDirectory = (Resolve-Path -LiteralPath $directoryFull -ErrorAction Stop).Path.TrimEnd('\', '/') $uninstaller = Get-Item -LiteralPath $registeredFull -Force -ErrorAction Stop $canonicalUninstaller = (Resolve-Path -LiteralPath $registeredFull -ErrorAction Stop).Path $executablePath = Join-Path $canonicalDirectory "FlintTrade.exe" $executable = Get-Item -LiteralPath $executablePath -Force -ErrorAction Stop $canonicalExecutable = (Resolve-Path -LiteralPath $executablePath -ErrorAction Stop).Path } catch { return $null } if (-not $directory.PSIsContainer -or ($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $uninstaller.PSIsContainer -or ($uninstaller.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $executable.PSIsContainer -or ($executable.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $null } if (-not $canonicalDirectory.Equals($directoryFull, [StringComparison]::OrdinalIgnoreCase) -or -not $canonicalUninstaller.Equals($registeredFull, [StringComparison]::OrdinalIgnoreCase) -or -not (Split-Path -Parent $canonicalUninstaller).Equals($canonicalDirectory, [StringComparison]::OrdinalIgnoreCase) -or -not (Split-Path -Parent $canonicalExecutable).Equals($canonicalDirectory, [StringComparison]::OrdinalIgnoreCase)) { return $null } if ($Entry.InstallLocation) { try { $installLocation = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables([string]$Entry.InstallLocation)).TrimEnd('\', '/') } catch { return $null } if (-not $installLocation.Equals($canonicalDirectory, [StringComparison]::OrdinalIgnoreCase)) { return $null } } if ($Entry.QuietUninstallString) { $quietCommand = Get-CommandExecutable ([string]$Entry.QuietUninstallString) try { $quietFull = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($quietCommand)).TrimEnd('\', '/') } catch { return $null } if (-not $quietFull.Equals($canonicalUninstaller, [StringComparison]::OrdinalIgnoreCase)) { return $null } } if ($Entry.DisplayIcon) { $displayIcon = ([string]$Entry.DisplayIcon).Trim() -replace ',\d+$', '' $displayIcon = $displayIcon.Trim('"') try { $displayIcon = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($displayIcon)).TrimEnd('\', '/') } catch { return $null } $allowedIcons = @($canonicalExecutable, (Join-Path $canonicalDirectory "uninstallerIcon.ico")) if (-not @($allowedIcons | Where-Object { $_.Equals($displayIcon, [StringComparison]::OrdinalIgnoreCase) })) { return $null } } [pscustomobject]@{ Directory = $canonicalDirectory ExecutablePath = $canonicalExecutable UninstallerPath = $canonicalUninstaller RegistryPath = [string]$Entry.PSPath } } function Test-RegistryEntryStillBoundToRecord($Entry, $Record) { if (-not $Entry -or -not $Record -or $Entry.DisplayName -cne "FlintTrade" -or -not $Entry.UninstallString) { return $false } $command = Get-CommandExecutable ([string]$Entry.UninstallString) try { $command = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($command)).TrimEnd('\', '/') } catch { return $false } if (-not $command.Equals($Record.UninstallerPath, [StringComparison]::OrdinalIgnoreCase)) { return $false } if ($Entry.InstallLocation) { try { $location = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables([string]$Entry.InstallLocation)).TrimEnd('\', '/') } catch { return $false } if (-not $location.Equals($Record.Directory, [StringComparison]::OrdinalIgnoreCase)) { return $false } } if ($Entry.QuietUninstallString) { $quietCommand = Get-CommandExecutable ([string]$Entry.QuietUninstallString) try { $quietCommand = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($quietCommand)).TrimEnd('\', '/') } catch { return $false } if (-not $quietCommand.Equals($Record.UninstallerPath, [StringComparison]::OrdinalIgnoreCase)) { return $false } } if ($Entry.DisplayIcon) { $displayIcon = ([string]$Entry.DisplayIcon).Trim() -replace ',\d+$', '' $displayIcon = $displayIcon.Trim('"') try { $displayIcon = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($displayIcon)).TrimEnd('\', '/') } catch { return $false } $allowedIcons = @($Record.ExecutablePath, (Join-Path $Record.Directory "uninstallerIcon.ico")) if (-not @($allowedIcons | Where-Object { $_.Equals($displayIcon, [StringComparison]::OrdinalIgnoreCase) })) { return $false } } return $true } function Stop-ProvenShellProcesses($Record) { if (-not $Record) { return } $expectedPath = [string]$Record.ExecutablePath foreach ($process in @(Get-Process -Name "FlintTrade" -ErrorAction SilentlyContinue)) { try { $processPath = (Resolve-Path -LiteralPath ([string]$process.Path) -ErrorAction Stop).Path } catch { throw "Uninstall refused: shell identity is uncertain; processes and receipts retained." } if (-not $expectedPath.Equals($processPath, [StringComparison]::OrdinalIgnoreCase)) { continue } if ($DryRun) { Say "[dry-run] running shell requires verified safe-maintenance before uninstall" continue } # A path/start-time snapshot does not retain the Windows process handle # or authorise abandoning protection. Never signal a reusable PID here. throw "Uninstall refused: running shell requires verified safe-maintenance; processes and receipts retained." } } function Get-ShortcutTarget([string]$ShortcutPath) { try { $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($ShortcutPath) if (-not $shortcut.TargetPath) { return $null } return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables([string]$shortcut.TargetPath)) } catch { return $null } } function Remove-ProvenShortcuts($Record) { $shortcutDirectory = Join-Path $RoamingAppDataRoot "Microsoft\Windows\Start Menu\Programs\FlintTrade" $shortcutPaths = @( (Join-Path $RoamingAppDataRoot "Microsoft\Windows\Start Menu\Programs\FlintTrade.lnk"), (Join-Path $shortcutDirectory "FlintTrade.lnk") ) # Guarded on its root like every other Join-Path here: the Desktop known # folder resolves to an empty string on a profile that has none (a redirected # or freshly provisioned one), and Join-Path then throws a raw binder error # that aborted the whole uninstall part-way through. $desktopRoot = [Environment]::GetFolderPath("Desktop") if ($desktopRoot) { $shortcutPaths += (Join-Path $desktopRoot "FlintTrade.lnk") } foreach ($shortcutPath in $shortcutPaths) { if (-not (Test-Path -LiteralPath $shortcutPath)) { continue } $safe = $false if ($Record -and -not (Test-PathContainsReparsePoint $shortcutPath)) { $target = Get-ShortcutTarget $shortcutPath $safe = $target -and $Record.ExecutablePath.Equals($target, [StringComparison]::OrdinalIgnoreCase) } # A shortcut left by the retired pre-Electron shell is equally proven # once that shell's own identity has been proved from the registry. if (-not $safe -and $script:LegacyShellRecord -and -not (Test-PathContainsReparsePoint $shortcutPath)) { $legacyTarget = Get-ShortcutTarget $shortcutPath $safe = $legacyTarget -and $script:LegacyShellRecord.ExecutablePath.Equals( $legacyTarget, [StringComparison]::OrdinalIgnoreCase) } if (-not $safe) { Warn "Leaving an unproven same-name shell shortcut at $shortcutPath." $script:FailedAny = $true continue } Remove-IfExists $shortcutPath } if (Test-Path -LiteralPath $shortcutDirectory -PathType Container) { $children = @(Get-ChildItem -LiteralPath $shortcutDirectory -Force -ErrorAction SilentlyContinue) $shortcutOwnerProven = $Record -or $script:LegacyShellRecord if ($shortcutOwnerProven -and $children.Count -eq 0 -and -not (Test-PathContainsReparsePoint $shortcutDirectory)) { if ($DryRun) { Say "[dry-run] would remove empty shortcut directory $shortcutDirectory" } else { try { Remove-Item -LiteralPath $shortcutDirectory -Force -ErrorAction Stop $script:RemovedAny = $true } catch { Warn "Could not remove empty shortcut directory ${shortcutDirectory}: $($_.Exception.Message)" $script:FailedAny = $true } } } else { Warn "Leaving non-empty same-name shortcut directory at $shortcutDirectory." $script:FailedAny = $true } } } function Get-ProvenLegacyShellRecord($Entries) { # The retired pre-Electron shell registered an NSIS "uninstall.exe" — a leaf # name Get-ProvenInstallRecord deliberately rejects, so the legacy install # was invisible to the uninstaller while its registry entry was reported as # unproven residue. Prove it exactly the way flinttrade-install.ps1's # Assert-VerifiedLegacyShell does before touching anything. if (-not $LegacyShellInstallDir -or -not (Test-Path -LiteralPath $LegacyShellInstallDir)) { return $null } if (Test-PathContainsReparsePoint $LegacyShellInstallDir) { Warn "Leaving the legacy FlintTrade shell at $LegacyShellInstallDir because its path contains a reparse alias." $script:FailedAny = $true return $null } try { $directory = Get-Item -LiteralPath $LegacyShellInstallDir -Force -ErrorAction Stop $canonicalDirectory = (Resolve-Path -LiteralPath $LegacyShellInstallDir -ErrorAction Stop).Path.TrimEnd('\', '/') $executablePath = Join-Path $canonicalDirectory "FlintTrade.exe" $uninstallerPath = Join-Path $canonicalDirectory "uninstall.exe" $executable = Get-Item -LiteralPath $executablePath -Force -ErrorAction Stop $legacyUninstaller = Get-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop $canonicalExecutable = (Resolve-Path -LiteralPath $executablePath -ErrorAction Stop).Path $canonicalUninstaller = (Resolve-Path -LiteralPath $uninstallerPath -ErrorAction Stop).Path } catch { Warn "Leaving an unproven same-name directory at ${LegacyShellInstallDir}: no legacy shell identity could be read." $script:FailedAny = $true return $null } if (-not $directory.PSIsContainer -or ($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $executable.PSIsContainer -or ($executable.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $legacyUninstaller.PSIsContainer -or ($legacyUninstaller.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or -not (Split-Path -Parent $canonicalExecutable).Equals($canonicalDirectory, [StringComparison]::OrdinalIgnoreCase) -or -not (Split-Path -Parent $canonicalUninstaller).Equals($canonicalDirectory, [StringComparison]::OrdinalIgnoreCase)) { Warn "Leaving an unproven same-name directory at $LegacyShellInstallDir." $script:FailedAny = $true return $null } $matched = $null foreach ($entry in @($Entries)) { if (-not $entry.PSPath -or -not $entry.UninstallString) { continue } $command = Get-CommandExecutable ([string]$entry.UninstallString) if (-not $command) { continue } try { $commandFull = [System.IO.Path]::GetFullPath( [Environment]::ExpandEnvironmentVariables($command) ).TrimEnd('\', '/') } catch { continue } if (-not $commandFull.Equals($canonicalUninstaller, [StringComparison]::OrdinalIgnoreCase)) { continue } if ($matched) { Warn "Multiple registry entries claim the legacy FlintTrade shell; refusing ambiguous automatic retirement." $script:FailedAny = $true return $null } $matched = $entry } if (-not $matched) { Warn "Leaving the legacy FlintTrade shell at $canonicalDirectory because no registry entry proves its uninstaller." $script:FailedAny = $true return $null } [pscustomobject]@{ Directory = $canonicalDirectory ExecutablePath = $canonicalExecutable UninstallerPath = $canonicalUninstaller RegistryPath = [string]$matched.PSPath } } function Remove-ProvenLegacyShell($LegacyRecord) { # Symmetric with the POSIX uninstaller's legacy cleanup, and with # flinttrade-install.ps1's Remove-VerifiedLegacyShell: delete the proved # directory outright, then drop the registry entry only while it still names # the exact uninstaller that was proved. if (-not $LegacyRecord) { return } if ($DryRun) { Say "[dry-run] would retire the legacy FlintTrade shell at $($LegacyRecord.Directory)" Say "[dry-run] would remove legacy registry entry $($LegacyRecord.RegistryPath)" return } Say "Retiring the identity-proven legacy FlintTrade shell at $($LegacyRecord.Directory)..." Remove-IfExists $LegacyRecord.Directory if (Test-Path -LiteralPath $LegacyRecord.Directory) { Warn "Keeping the legacy FlintTrade registry entry because $($LegacyRecord.Directory) remains." $script:FailedAny = $true return } $script:ShellRemovedAny = $true if (-not (Test-Path -LiteralPath $LegacyRecord.RegistryPath)) { return } $currentEntry = Get-ItemProperty -LiteralPath $LegacyRecord.RegistryPath -ErrorAction SilentlyContinue $currentCommand = if ($currentEntry) { Get-CommandExecutable ([string]$currentEntry.UninstallString) } else { "" } if ($currentCommand) { try { $currentCommand = [System.IO.Path]::GetFullPath( [Environment]::ExpandEnvironmentVariables($currentCommand) ).TrimEnd('\', '/') } catch { $currentCommand = "" } } if (-not $currentCommand -or -not $currentCommand.Equals($LegacyRecord.UninstallerPath, [StringComparison]::OrdinalIgnoreCase)) { Warn "Leaving the legacy FlintTrade registry entry because its identity changed during uninstall." $script:FailedAny = $true return } try { Remove-Item -LiteralPath $LegacyRecord.RegistryPath -Recurse -Force -ErrorAction Stop $script:RemovedAny = $true } catch { Warn "Could not remove legacy registry entry $($LegacyRecord.RegistryPath): $($_.Exception.Message)" $script:FailedAny = $true } } # Before the shell sweep, so a proved web launcher and its Start Menu shortcut # are already gone by the time the same-name checks below look at # $DefaultInstallDir and the shortcut directory. Remove-ProvenWebInstall $installEntries = @(Get-InstallEntries) $candidateRecords = @($installEntries | ForEach-Object { Get-ProvenInstallRecord $_ } | Where-Object { $_ }) $record = if ($candidateRecords.Count -eq 1) { $candidateRecords[0] } else { $null } if ($candidateRecords.Count -gt 1) { Warn "Multiple registered FlintTrade install identities were found; refusing ambiguous automatic removal." $script:FailedAny = $true } if ($record) { $refreshedEntry = Get-ItemProperty -LiteralPath $record.RegistryPath -ErrorAction SilentlyContinue $refreshedRecord = if ($refreshedEntry) { Get-ProvenInstallRecord $refreshedEntry } else { $null } $identityStillMatches = $refreshedRecord -and $refreshedRecord.Directory.Equals($record.Directory, [StringComparison]::OrdinalIgnoreCase) -and $refreshedRecord.ExecutablePath.Equals($record.ExecutablePath, [StringComparison]::OrdinalIgnoreCase) -and $refreshedRecord.UninstallerPath.Equals($record.UninstallerPath, [StringComparison]::OrdinalIgnoreCase) if (-not $identityStillMatches) { Warn "The registered FlintTrade install identity changed during validation; refusing automatic removal." $script:FailedAny = $true $record = $null } } $script:LegacyShellRecord = Get-ProvenLegacyShellRecord $installEntries # An empty directory is not an install. The web installer's launcher shim lives # in this same directory, so once its receipt has proved and removed the shim # what remains is an empty folder, not unproven residue to fail the run over. $defaultCollision = $false if (Test-Path -LiteralPath $DefaultInstallDir -PathType Container) { $defaultCollision = @(Get-ChildItem -LiteralPath $DefaultInstallDir -Force -ErrorAction SilentlyContinue).Count -gt 0 } elseif (Test-Path -LiteralPath $DefaultInstallDir) { $defaultCollision = $true } if (-not $record -and $defaultCollision) { Warn "Leaving an unproven same-name install directory at $DefaultInstallDir." $script:FailedAny = $true } $provenRegistryPath = if ($record) { [string]$record.RegistryPath } else { "" } $legacyRegistryPath = if ($script:LegacyShellRecord) { [string]$script:LegacyShellRecord.RegistryPath } else { "" } $unprovenInstallEntries = @($installEntries | Where-Object { $entryPath = [string]$_.PSPath (-not $provenRegistryPath -or -not $entryPath.Equals($provenRegistryPath, [StringComparison]::OrdinalIgnoreCase)) -and (-not $legacyRegistryPath -or -not $entryPath.Equals($legacyRegistryPath, [StringComparison]::OrdinalIgnoreCase)) }) Stop-ProvenShellProcesses $record Stop-ProvenShellProcesses $script:LegacyShellRecord if ($record) { $executionEntry = Get-ItemProperty -LiteralPath $record.RegistryPath -ErrorAction SilentlyContinue $executionRecord = if ($executionEntry) { Get-ProvenInstallRecord $executionEntry } else { $null } $executionIdentityMatches = $executionRecord -and $executionRecord.RegistryPath.Equals($record.RegistryPath, [StringComparison]::OrdinalIgnoreCase) -and $executionRecord.Directory.Equals($record.Directory, [StringComparison]::OrdinalIgnoreCase) -and $executionRecord.ExecutablePath.Equals($record.ExecutablePath, [StringComparison]::OrdinalIgnoreCase) -and $executionRecord.UninstallerPath.Equals($record.UninstallerPath, [StringComparison]::OrdinalIgnoreCase) if (-not $executionIdentityMatches) { Warn "The registered FlintTrade install identity changed before execution; refusing to run it." $script:FailedAny = $true $record = $null } else { $record = $executionRecord } } if ($record) { if ($DryRun) { Say "[dry-run] would run $($record.UninstallerPath) /S _?=$($record.Directory)" } else { try { Say "Running the exact registered FlintTrade uninstaller..." $process = Start-Process -FilePath $record.UninstallerPath -ArgumentList @("/S", "_?=$($record.Directory)") -Wait -PassThru if ($process.ExitCode -ne 0) { throw "uninstaller exited with code $($process.ExitCode)" } $script:RemovedAny = $true $script:ShellRemovedAny = $true } catch { Warn "Could not run the registered FlintTrade uninstaller: $($_.Exception.Message)" $script:FailedAny = $true } } } else { Say "No identity-verified FlintTrade uninstaller found; leaving same-name shell residue unmodified." } # Retire the legacy shell before the shortcut sweep so its own Start Menu entry # is evaluated against a record that still exists. Remove-ProvenLegacyShell $script:LegacyShellRecord $directoryRemains = $record -and (Test-Path -LiteralPath $record.Directory) if ($record -and -not $DryRun -and $directoryRemains) { Warn "The registered uninstaller left the proved install directory in place; preserving it and its registry proof for retry." $script:FailedAny = $true } if ($DryRun -or -not $directoryRemains) { Remove-ProvenShortcuts $record } foreach ($entry in $unprovenInstallEntries) { if ($entry.PSPath -and (Test-Path -LiteralPath $entry.PSPath)) { Warn "Leaving an unproven FlintTrade registry entry at $($entry.PSPath)." $script:FailedAny = $true } } if ($record) { if (-not (Test-Path -LiteralPath $record.RegistryPath)) { $script:RemovedAny = $true } elseif (-not $DryRun -and $directoryRemains) { Warn "Keeping the exact FlintTrade registry entry because the proved install directory remains." $script:FailedAny = $true } else { $currentEntry = Get-ItemProperty -LiteralPath $record.RegistryPath -ErrorAction SilentlyContinue if (-not (Test-RegistryEntryStillBoundToRecord $currentEntry $record)) { Warn "Leaving the FlintTrade registry entry because its identity changed during uninstall." $script:FailedAny = $true } elseif ($DryRun) { Say "[dry-run] would remove registry entry $($record.RegistryPath)" } else { try { Remove-Item -LiteralPath $record.RegistryPath -Recurse -Force -ErrorAction Stop $script:RemovedAny = $true } catch { Warn "Could not remove registry entry $($record.RegistryPath): $($_.Exception.Message)" $script:FailedAny = $true } } } } function Test-WebReceiptNamesSource([string]$Target) { if (-not $script:WebReceipt -or -not $script:WebReceipt.Source) { return $false } $full = Resolve-AbsoluteFlintPath $Target if (-not $full) { return $false } return $full.Equals([string]$script:WebReceipt.Source, [StringComparison]::OrdinalIgnoreCase) } function Test-ProvenSourceCheckout([string]$Target) { # An FLINTTRADE_SRC_DIR override is only honoured when an installer receipt # proves the checkout is FlintTrade's own; an arbitrary env var must never be # able to aim a recursive delete at, say, the user's Documents folder. if (-not $Target -or -not (Test-Path -LiteralPath $Target -PathType Container)) { return $false } try { $directory = Get-Item -LiteralPath $Target -Force -ErrorAction Stop if ($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $false } } catch { return $false } foreach ($marker in @(".git", "pnpm-lock.yaml", "uv.lock", "pyproject.toml")) { if (-not (Test-Path -LiteralPath (Join-Path $Target $marker))) { return $false } } # Shape is not identity: those four markers are every contributor clone of # this repository. Recursive deletion of a source checkout is authorised only # by an installer-written receipt, exactly as the shell-removal path requires # its own registry proof. return (Test-WebReceiptNamesSource $Target) } function Get-DataTargets { $provenOverride = "" if ($SrcDirOverride) { if (Test-ProvenSourceCheckout $SrcDirOverride) { $provenOverride = $SrcDirOverride } elseif (Test-Path -LiteralPath $SrcDirOverride -PathType Container) { Say "Leaving $SrcDirOverride - no FlintTrade installer receipt proves this source checkout." } } $webShim = "" $webShortcut = "" $webSource = "" $webTools = "" if ($script:WebReceipt) { $webSource = [string]$script:WebReceipt.Source $webTools = [string]$script:WebReceipt.Tools # Only a launcher whose digest still matched the receipt is purge-eligible; # -Purge must not finish a deletion the ordinary path already refused. if ($script:WebShimProven) { $webShim = [string]$script:WebReceipt.Shim $webShortcut = [string]$script:WebReceipt.Shortcut } } $candidates = @( $WorkspaceDir, $DefaultWorkspace, $ElectronProfile, $SourceRoot, $ToolsRoot, $SourceBuildRoot, $provenOverride, $webSource, $webTools, $webShim, $webShortcut, $LegacyDataDir, $LegacyArchiveDir, $LegacySandboxDir, $LegacyDittoVault, # Every root-level dropping, named individually. Each is retained by the # copy-once workspace migration, so on an upgraded install it is a live # second copy of real trading state, not residue. $LegacyFlowsDir, $LegacyModelsDir, $LegacyStrategiesDir, $LegacyScreenshotsDir, $LegacyTotpDb, $LegacyTotpInstallKey, $LegacyJournalDb, $LegacyShortcutsDb, $LegacyQtyFreezeDb, $LegacyActionCenterDb, $LegacyWatchlistDb, $LegacyPresetsFile, $LegacyLatencyDb, $LegacyTrafficDb, # The managed root itself, after the specific paths above so the printed # list still names them explicitly. Around nineteen modules wrote # DIRECTLY at .flinttrade\ on Windows, so enumerating only the # subdirectories left TOTP secrets and realised P&L state behind while # claiming everything had been purged. $ManagedRoot, (Join-Path $RoamingAppDataRoot $LegacyBundleId), (Join-Path $LocalAppDataRoot $LegacyBundleId), # Ordinary uninstall retains irreversible lineage. Explicitly confirmed # purge removes it last, after every workspace/source target. $InstallationStateRoot ) foreach ($candidate in $candidates[0..($candidates.Count - 2)]) { if ($candidate -and (Test-Path -LiteralPath $candidate) -and (Test-PathsOverlap $candidate $InstallationStateRoot)) { $script:LineageOverlapFound = $true } } $seen = @{} foreach ($candidate in $candidates) { if (-not $candidate -or -not (Test-Path -LiteralPath $candidate)) { continue } $key = $candidate.TrimEnd('\', '/').ToLowerInvariant() if (-not $seen.ContainsKey($key)) { $seen[$key] = $true $candidate } } } function Test-PathsOverlap([string]$Left, [string]$Right) { try { $leftFull = (Resolve-Path -LiteralPath $Left -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $leftFull = [System.IO.Path]::GetFullPath($Left).TrimEnd('\', '/') } try { $rightFull = (Resolve-Path -LiteralPath $Right -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $rightFull = [System.IO.Path]::GetFullPath($Right).TrimEnd('\', '/') } $separator = [System.IO.Path]::DirectorySeparatorChar return $leftFull.Equals($rightFull, [StringComparison]::OrdinalIgnoreCase) -or $leftFull.StartsWith($rightFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $rightFull.StartsWith($leftFull + $separator, [StringComparison]::OrdinalIgnoreCase) } function Test-SameResolvedPath([string]$Left, [string]$Right) { try { $leftFull = (Resolve-Path -LiteralPath $Left -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $leftFull = [System.IO.Path]::GetFullPath($Left).TrimEnd('\', '/') } try { $rightFull = (Resolve-Path -LiteralPath $Right -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $rightFull = [System.IO.Path]::GetFullPath($Right).TrimEnd('\', '/') } return $leftFull.Equals($rightFull, [StringComparison]::OrdinalIgnoreCase) } function Test-PurgeTargetPresent([string]$Target) { try { Get-Item -LiteralPath $Target -Force -ErrorAction Stop | Out-Null return $true } catch { return $false } } function Test-EarlierPurgeTargetsAbsent([object[]]$Targets) { foreach ($target in $Targets) { if (Test-PurgeTargetPresent ([string]$target)) { Warn "Keeping installation lineage because earlier data still exists at $target." $script:FailedAny = $true $script:DataRetainedAny = $true return $false } } return $true } function Test-ProvenCustomWorkspace([string]$Target) { try { $directory = Get-Item -LiteralPath $Target -Force -ErrorAction Stop if (-not $directory.PSIsContainer -or ($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $false } $workspace = Get-Item -LiteralPath (Join-Path $Target "workspace.json") -Force -ErrorAction Stop if ($workspace.PSIsContainer -or ($workspace.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $false } foreach ($name in @("credentials.db", "auth.db", "security.db", "master_password", "api_key_pepper", "safety_gate_secret")) { $path = Join-Path $Target $name if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } $marker = Get-Item -LiteralPath $path -Force if (-not ($marker.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $true } } } catch { return $false } return $false } function Test-SafePurgeTarget([string]$Target) { $full = [System.IO.Path]::GetFullPath($Target).TrimEnd('\', '/') try { $canonical = (Resolve-Path -LiteralPath $Target -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $canonical = $full } $homeFull = [System.IO.Path]::GetFullPath($HOME).TrimEnd('\', '/') try { $homeCanonical = (Resolve-Path -LiteralPath $HOME -ErrorAction Stop).Path.TrimEnd('\', '/') } catch { $homeCanonical = $homeFull } $homePrefix = $homeFull + [System.IO.Path]::DirectorySeparatorChar $homeCanonicalPrefix = $homeCanonical + [System.IO.Path]::DirectorySeparatorChar $root = [System.IO.Path]::GetPathRoot($full).TrimEnd('\', '/') if ($script:LineageOverlapFound -and (Test-PathsOverlap $Target $InstallationStateRoot)) { Warn "Refusing to purge $Target because an earlier data target overlaps installation lineage." $script:FailedAny = $true return $false } try { Get-Item -LiteralPath $Target -Force -ErrorAction Stop | Out-Null } catch { return $false } if (Test-PathContainsReparsePoint $Target) { Warn "Refusing to purge $Target because the target or one of its ancestors is a reparse point." $script:FailedAny = $true return $false } if (-not $full.StartsWith($homePrefix, [StringComparison]::OrdinalIgnoreCase) -or -not $canonical.StartsWith($homeCanonicalPrefix, [StringComparison]::OrdinalIgnoreCase)) { Warn "Refusing to purge $Target because it is outside the current user's home." $script:FailedAny = $true return $false } if (-not $full -or $full -eq $homeFull -or $canonical -eq $homeCanonical -or $full -eq $root) { Warn "Refusing to purge $Target because it is not a FlintTrade data directory." $script:FailedAny = $true return $false } $workspaceFull = [System.IO.Path]::GetFullPath($WorkspaceDir).TrimEnd('\', '/') $defaultFull = [System.IO.Path]::GetFullPath($DefaultWorkspace).TrimEnd('\', '/') if ($full.Equals($workspaceFull, [StringComparison]::OrdinalIgnoreCase) -and -not $workspaceFull.Equals($defaultFull, [StringComparison]::OrdinalIgnoreCase) -and -not (Test-ProvenCustomWorkspace $Target)) { Warn "Refusing to purge $Target because custom workspace identity is not proven." $script:FailedAny = $true return $false } return $true } $dataTargets = @(Get-DataTargets) if ($Purge) { $purgeTargets = @($dataTargets | Where-Object { Test-SafePurgeTarget $_ }) if (-not $dataTargets) { Say "No FlintTrade data to purge." } elseif (-not $purgeTargets) { Say "No identity-proven FlintTrade data was eligible for purge." } elseif ($DryRun) { $purgeTargets | ForEach-Object { Say "[dry-run] would DELETE FlintTrade data at $_" } } else { throw "Purge requires the installed held-exclusion maintenance helper; PowerShell deletion is disabled." } } elseif ($dataTargets) { $script:DataRetainedAny = $true Say "The following FlintTrade data was kept:" $dataTargets | ForEach-Object { Say " $_" } Say "This includes the workspace, Electron profile, managed source/tools, the source-build" Say "checkout, the whole .flinttrade managed root (its top-level TOTP, journal and" Say "screenshots, shortcuts, quantity-freeze, action-centre, watchlist, presets, flows," Say "models and strategies state included), any pre-workspace .flinttrade" Say "data/archive/sandbox storage (including the encrypted broker-credential vault) and any" Say "legacy desktop storage." Say "The stable installation identity and consumed migration tombstones are retained too." Say "To delete it too, re-run with -Purge and confirm explicitly." } # After the purge/keep decision above, so a confirmed -Purge that has just # removed the recorded source and tools retires the receipt it was kept for. Remove-RetainedWebReceipt if ($DryRun) { Say "Dry run complete; nothing was deleted." } elseif ($script:FailedAny) { throw "[flinttrade] Uninstall finished with some paths left behind (see above)." } elseif ($script:PurgedDataAny -and $script:PurgeCompleted) { Say "FlintTrade cleanup completed; explicitly confirmed data was purged." } elseif ($script:RemovedAny) { # Removing only the one-line web install's launcher is not a shell uninstall, # and saying so would be untrue on a machine that never had the desktop shell. $removalSubject = "FlintTrade shell uninstalled cleanly" if (-not $script:ShellRemovedAny -and $script:WebRemovedAny) { $removalSubject = "FlintTrade web-app launcher removed cleanly" } if ($script:DataRetainedAny) { Say "${removalSubject}; retained data remains available for reinstall." } elseif ($Purge) { Say "${removalSubject}; no recognised FlintTrade data was found to purge." } else { Say "${removalSubject}; no recognised FlintTrade data was found." } } elseif ($script:DataRetainedAny) { Say "No FlintTrade shell was removed; retained data remains available for reinstall." } else { Say "Nothing to remove; the FlintTrade shell does not appear to be installed." }