scripts/install/flinttrade-uninstall.ps1
Rama main · réplica de lectura del repositorio original.
# 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\<name> 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\<name> 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."
}