cc-haha/desktop/build/recover-legacy-install-data.ps1
2026-07-11 21:09:19 +08:00

973 lines
41 KiB
PowerShell

[CmdletBinding()]
param(
[string]$PerUserInstallDir = '',
[string]$PerMachineInstallDir = '',
[string]$CandidateInstallDir = '',
[string]$UserDataDir = '',
[string]$RecoveryRoot = '',
[string]$ProcessName = 'Claude Code Haha.exe',
[string]$ActiveConfigDir = $env:CLAUDE_CONFIG_DIR,
[string]$ActiveConfigManaged = $env:CC_HAHA_APP_PORTABLE_DIR,
[ValidateSet('trusted-user', 'trusted-uac-outer', 'untrusted-elevated')]
[string]$InstallerIdentitySafety = 'trusted-user',
[switch]$SkipProcessCheck,
[switch]$SelfTest
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not ('CcHahaRecoveryNativePath' -as [type])) {
Add-Type @'
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class CcHahaRecoveryNativePath
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandle(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
public static string Resolve(string path)
{
const uint shareReadWriteDelete = 0x00000007;
const uint openExisting = 3;
const uint backupSemantics = 0x02000000;
using (SafeFileHandle handle = CreateFile(
path,
0,
shareReadWriteDelete,
IntPtr.Zero,
openExisting,
backupSemantics,
IntPtr.Zero))
{
if (handle.IsInvalid) {
throw new Win32Exception(
Marshal.GetLastWin32Error(),
"Cannot resolve the final path for " + path);
}
StringBuilder result = new StringBuilder(32768);
uint length = GetFinalPathNameByHandle(handle, result, (uint)result.Capacity, 0);
if (length == 0) {
throw new Win32Exception(
Marshal.GetLastWin32Error(),
"Cannot resolve the final path for " + path);
}
if (length >= (uint)result.Capacity) {
throw new InvalidOperationException("Resolved path is too long: " + path);
}
string value = result.ToString();
if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) {
return @"\\" + value.Substring(8);
}
return value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)
? value.Substring(4)
: value;
}
}
}
'@
}
function Resolve-CanonicalPath {
param([Parameter(Mandatory = $true)][string]$Path)
$fullPath = [IO.Path]::GetFullPath($Path)
$existingPath = $fullPath
$missingSegments = New-Object 'System.Collections.Generic.List[string]'
while (-not (Test-Path -LiteralPath $existingPath)) {
$parent = [IO.Path]::GetDirectoryName($existingPath)
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $existingPath) {
throw "Cannot resolve an existing ancestor for $Path"
}
$missingSegments.Insert(0, [IO.Path]::GetFileName($existingPath))
$existingPath = $parent
}
$resolved = [CcHahaRecoveryNativePath]::Resolve($existingPath)
foreach ($segment in $missingSegments) {
$resolved = Join-Path $resolved $segment
}
return [IO.Path]::GetFullPath($resolved)
}
function Test-PathAtOrBelow {
param(
[Parameter(Mandatory = $true)][string]$Parent,
[Parameter(Mandatory = $true)][string]$Candidate
)
$resolvedParent = (Resolve-CanonicalPath $Parent).TrimEnd('\', '/')
$resolvedCandidate = (Resolve-CanonicalPath $Candidate).TrimEnd('\', '/')
if ($resolvedCandidate.Equals($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) {
return $true
}
return $resolvedCandidate.StartsWith(
$resolvedParent + [IO.Path]::DirectorySeparatorChar,
[StringComparison]::OrdinalIgnoreCase)
}
function Test-LexicalPathAtOrBelow {
param(
[Parameter(Mandatory = $true)][string]$Parent,
[Parameter(Mandatory = $true)][string]$Candidate
)
$fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/')
$fullCandidate = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/')
if ($fullCandidate.Equals($fullParent, [StringComparison]::OrdinalIgnoreCase)) {
return $true
}
return $fullCandidate.StartsWith(
$fullParent + [IO.Path]::DirectorySeparatorChar,
[StringComparison]::OrdinalIgnoreCase)
}
function Test-PathMayBeDeleted {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
[Parameter(Mandatory = $true)][string]$Candidate
)
return (Test-LexicalPathAtOrBelow -Parent $InstallDir -Candidate $Candidate) -or
(Test-PathAtOrBelow -Parent $InstallDir -Candidate $Candidate)
}
function Test-SamePath {
param(
[Parameter(Mandatory = $true)][string]$Left,
[Parameter(Mandatory = $true)][string]$Right
)
return (Resolve-CanonicalPath $Left).TrimEnd('\', '/').Equals(
(Resolve-CanonicalPath $Right).TrimEnd('\', '/'),
[StringComparison]::OrdinalIgnoreCase)
}
function Read-AppMode {
param([Parameter(Mandatory = $true)][string]$ConfigDir)
$modeFile = Join-Path $ConfigDir 'app-mode.json'
if (-not (Test-Path -LiteralPath $modeFile -PathType Leaf)) {
return $null
}
try {
$parsed = Get-Content -LiteralPath $modeFile -Raw | ConvertFrom-Json
} catch {
throw "Existing app mode metadata cannot be read safely ($modeFile): $($_.Exception.Message)"
}
if ($null -eq $parsed -or $parsed -isnot [pscustomobject]) {
throw "Existing app mode metadata is not a JSON object: $modeFile"
}
$modeProperty = $parsed.PSObject.Properties['mode']
if ($null -ne $modeProperty -and $modeProperty.Value -isnot [string]) {
throw "Existing app mode metadata has an invalid mode value: $modeFile"
}
$mode = if ($null -ne $modeProperty) {
([string]$modeProperty.Value).ToLowerInvariant()
} else {
'default'
}
if ($mode -notin @('default', 'portable')) {
throw "Existing app mode metadata has an unsupported mode '$mode': $modeFile"
}
$portableProperty = $parsed.PSObject.Properties['portable_dir']
if ($null -ne $portableProperty -and
$null -ne $portableProperty.Value -and
$portableProperty.Value -isnot [string]) {
throw "Existing app mode metadata has an invalid portable_dir value: $modeFile"
}
$portableDir = if ($null -ne $portableProperty -and $portableProperty.Value -is [string]) {
([string]$portableProperty.Value).Trim()
} else {
$null
}
return [pscustomobject]@{
Mode = $mode
PortableDir = $portableDir
}
}
function Test-LegacyPortableData {
param([Parameter(Mandatory = $true)][string]$Dir)
if (-not (Test-Path -LiteralPath $Dir -PathType Container)) {
return $false
}
foreach ($file in @('settings.json', '.claude.json', '.mcp.json', 'window-state.json', 'terminal-config.json')) {
if (Test-Path -LiteralPath (Join-Path $Dir $file) -PathType Leaf) {
return $true
}
}
foreach ($childDir in @('Cache', 'EBWebView', 'projects', 'skills', 'plugins', 'cowork_plugins', 'cc-haha')) {
if (Test-Path -LiteralPath (Join-Path $Dir $childDir) -PathType Container) {
return $true
}
}
return $false
}
function Resolve-LegacyConfiguredPath {
param(
[Parameter(Mandatory = $true)][string]$Value,
[Parameter(Mandatory = $true)][string]$Source
)
if (-not [IO.Path]::IsPathRooted($Value)) {
throw "Legacy custom data path is relative and cannot be recovered safely ($Source): $Value"
}
return [IO.Path]::GetFullPath($Value)
}
function Get-LegacyActiveSource {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
$SystemMode
)
$legacyDir = Join-Path $InstallDir 'CLAUDE_CONFIG_DIR'
$legacyMode = Read-AppMode -ConfigDir $legacyDir
if ($null -ne $legacyMode) {
if ($legacyMode.Mode -ne 'portable') {
return $null
}
if (Test-LegacyPortableData -Dir $legacyDir) {
return $legacyDir
}
if ([string]::IsNullOrWhiteSpace([string]$legacyMode.PortableDir)) {
return $legacyDir
}
return Resolve-LegacyConfiguredPath -Value $legacyMode.PortableDir -Source (Join-Path $legacyDir 'app-mode.json')
}
if ($null -ne $SystemMode) {
if ($SystemMode.Mode -ne 'portable') {
return $null
}
if ([string]::IsNullOrWhiteSpace([string]$SystemMode.PortableDir)) {
return $legacyDir
}
return Resolve-LegacyConfiguredPath -Value $SystemMode.PortableDir -Source 'system app-mode.json'
}
if (Test-LegacyPortableData -Dir $legacyDir) {
return $legacyDir
}
return $null
}
function Get-ExistingInstallDirs {
param([string[]]$InstallDirs)
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$result = New-Object 'System.Collections.Generic.List[string]'
foreach ($installDir in $InstallDirs) {
if ([string]::IsNullOrWhiteSpace($installDir) -or -not (Test-Path -LiteralPath $installDir -PathType Container)) {
continue
}
$canonical = Resolve-CanonicalPath $installDir
if ($seen.Add($canonical)) {
$result.Add($canonical)
}
}
return $result.ToArray()
}
function Get-PotentialInstallDirs {
param([string[]]$InstallDirs)
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$result = New-Object 'System.Collections.Generic.List[string]'
foreach ($installDir in $InstallDirs) {
if ([string]::IsNullOrWhiteSpace($installDir)) {
continue
}
if (-not [IO.Path]::IsPathRooted($installDir)) {
throw "Application install directory is relative and cannot be checked safely: $installDir"
}
$fullPath = [IO.Path]::GetFullPath($installDir)
if ($seen.Add($fullPath)) {
$result.Add($fullPath)
}
}
return $result.ToArray()
}
function Get-UnsafeLegacySource {
param(
[Parameter(Mandatory = $true)][string[]]$InstallDirs,
[Parameter(Mandatory = $true)][string]$UserDataDir,
[AllowEmptyString()][string]$ActiveConfigDir,
[AllowEmptyString()][string]$ActiveConfigManaged
)
$sources = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase)
$activeInsideInstall = $false
$activeOutsideInstall = $false
$active = $null
if (-not [string]::IsNullOrWhiteSpace($ActiveConfigDir)) {
if (-not [IO.Path]::IsPathRooted($ActiveConfigDir)) {
throw "Active CLAUDE_CONFIG_DIR is relative and cannot be recovered safely: $ActiveConfigDir"
}
$active = [IO.Path]::GetFullPath($ActiveConfigDir)
foreach ($installDir in $InstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $active) {
$activeInsideInstall = $true
if ($ActiveConfigManaged -ne '1') {
throw "Active CLAUDE_CONFIG_DIR is managed outside Claude Code Haha and points inside an application install directory. Move or remove that environment variable before upgrading: $active"
}
if (Test-SamePath -Left $installDir -Right $active) {
throw "The active data directory is the application install root itself: $active"
}
if (Test-Path -LiteralPath $active -PathType Container) {
$canonicalActive = Resolve-CanonicalPath $active
if (-not $sources.ContainsKey($canonicalActive)) {
$sources.Add($canonicalActive, $active)
}
}
break
}
}
if (-not $activeInsideInstall) {
$activeOutsideInstall = $true
}
}
if ($activeOutsideInstall -and $ActiveConfigManaged -eq '1') {
$systemMode = Read-AppMode -ConfigDir $UserDataDir
if ($null -eq $systemMode -or
$systemMode.Mode -ne 'portable' -or
[string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) {
throw 'App-managed CLAUDE_CONFIG_DIR has no matching persisted custom mode. Restart the old app before upgrading so its active and saved data directories agree.'
}
$persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json'
if (-not (Test-SamePath -Left $active -Right $persistedActive)) {
throw "App-managed CLAUDE_CONFIG_DIR does not match persisted custom mode. Active: $active; persisted: $persistedActive"
}
} elseif ($activeOutsideInstall) {
$systemMode = $null
} else {
$systemMode = Read-AppMode -ConfigDir $UserDataDir
}
foreach ($installDir in $InstallDirs) {
$source = Get-LegacyActiveSource -InstallDir $installDir -SystemMode $systemMode
if ([string]::IsNullOrWhiteSpace([string]$source) -or
-not (Test-Path -LiteralPath $source -PathType Container)) {
continue
}
foreach ($possiblyDeletedRoot in $InstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $source) {
if (Test-SamePath -Left $possiblyDeletedRoot -Right $source) {
throw "The active data directory is the application install root itself: $source"
}
$canonicalSource = Resolve-CanonicalPath $source
if (-not $sources.ContainsKey($canonicalSource)) {
$sources.Add($canonicalSource, $source)
}
break
}
}
}
if ($activeOutsideInstall) {
if ($sources.Count -gt 0) {
throw "External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists. Refusing to remove data that may belong to another Windows user: $($sources.Values -join ', ')"
}
return $null
}
if ($sources.Count -gt 1) {
throw "Multiple distinct legacy data sources may be removed; refusing to guess which one is active: $($sources.Values -join ', ')"
}
if ($sources.Count -eq 1) {
return @($sources.Values)[0]
}
return $null
}
function Assert-NoUndiscoveredLegacySources {
param(
[Parameter(Mandatory = $true)][string[]]$InstallDirs,
[AllowNull()][AllowEmptyString()][string]$ActiveSource
)
foreach ($installDir in $InstallDirs) {
$pending = New-Object 'System.Collections.Generic.Stack[string]'
$pending.Push($installDir)
while ($pending.Count -gt 0) {
$currentDir = $pending.Pop()
foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) {
$attributes = [IO.File]::GetAttributes($entry)
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Application install tree contains a reparse point that prevents a complete legacy data scan: $entry"
}
if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) {
$pending.Push($entry)
continue
}
if (-not ([IO.Path]::GetFileName($entry)).Equals('app-mode.json', [StringComparison]::OrdinalIgnoreCase)) {
continue
}
$modeDir = [IO.Path]::GetDirectoryName($entry)
$mode = Read-AppMode -ConfigDir $modeDir
if ($null -eq $mode -or $mode.Mode -ne 'portable') {
continue
}
$candidate = if (Test-LegacyPortableData -Dir $modeDir) {
$modeDir
} elseif ([string]::IsNullOrWhiteSpace([string]$mode.PortableDir)) {
$modeDir
} else {
Resolve-LegacyConfiguredPath -Value $mode.PortableDir -Source $entry
}
if (-not (Test-Path -LiteralPath $candidate -PathType Container)) {
continue
}
$willBeRemoved = $false
foreach ($possiblyDeletedRoot in $InstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $candidate) {
$willBeRemoved = $true
break
}
}
if (-not $willBeRemoved) {
continue
}
if (-not [string]::IsNullOrWhiteSpace($ActiveSource) -and
(Test-SamePath -Left $ActiveSource -Right $candidate)) {
continue
}
throw "Install-contained custom data was configured by another Windows user or an undiscovered legacy mode. Refusing to remove it: $candidate (metadata: $entry)"
}
}
}
}
function Assert-NoRunningApplication {
param(
[Parameter(Mandatory = $true)][string[]]$InstallDirs,
[Parameter(Mandatory = $true)][string]$ProcessName
)
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
try {
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
} catch {
throw "Cannot verify whether the old application or one of its sidecars is still running: $($_.Exception.Message)"
}
$matching = New-Object 'System.Collections.Generic.List[string]'
$hasUnknownAppPath = $false
foreach ($process in $processes) {
$executablePath = [string]$process.ExecutablePath
if ([string]::IsNullOrWhiteSpace($executablePath)) {
if (([string]$process.Name).Equals($ProcessName, [StringComparison]::OrdinalIgnoreCase)) {
$hasUnknownAppPath = $true
}
continue
}
foreach ($installDir in $InstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $executablePath) {
$matching.Add($executablePath)
break
}
}
}
if ($matching.Count -eq 0 -and -not $hasUnknownAppPath) {
return
}
Start-Sleep -Milliseconds 500
} while ([DateTime]::UtcNow -lt $deadline)
if ($hasUnknownAppPath) {
throw "Cannot verify the executable path of a running $ProcessName process. Close the app and run the installer again."
}
throw "An application process is still running from an installation that may contain legacy data: $($matching -join ', '). Close the app and run the installer again."
}
function Get-FileSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
try {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '')
} finally {
$sha.Dispose()
}
} finally {
$stream.Dispose()
}
}
function Get-TreeManifest {
param([Parameter(Mandatory = $true)][string]$Root)
$rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/')
$rootAttributes = [IO.File]::GetAttributes($rootPath)
if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Legacy data root is a reparse point and cannot be copied without ambiguity: $rootPath"
}
$entries = New-Object 'System.Collections.Generic.List[string]'
$pending = New-Object 'System.Collections.Generic.Stack[string]'
$pending.Push($rootPath)
while ($pending.Count -gt 0) {
$currentDir = $pending.Pop()
foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) {
$attributes = [IO.File]::GetAttributes($entry)
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Legacy data contains a reparse point and recovery must stop: $entry"
}
$relative = [IO.Path]::GetFullPath($entry).Substring($rootPath.Length).TrimStart('\', '/')
if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) {
$entries.Add("D|$relative")
$pending.Push($entry)
} else {
$file = Get-Item -LiteralPath $entry -Force
$entries.Add("F|$relative|$($file.Length)|$(Get-FileSha256 -Path $entry)")
}
}
}
return @($entries.ToArray() | Sort-Object)
}
function Assert-TreeManifestsEqual {
param(
[Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Expected,
[Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Actual,
[Parameter(Mandatory = $true)][string]$Message
)
if ($Expected.Count -ne $Actual.Count -or
$null -ne (Compare-Object -ReferenceObject @($Expected) -DifferenceObject @($Actual) -CaseSensitive)) {
throw $Message
}
}
function Copy-VerifiedTree {
param(
[Parameter(Mandatory = $true)][string]$Source,
[Parameter(Mandatory = $true)][string]$RecoveryRoot
)
New-Item -ItemType Directory -Path $RecoveryRoot -Force | Out-Null
$id = [Guid]::NewGuid().ToString('N')
$timestamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
$staging = Join-Path $RecoveryRoot ".$id.partial"
$destination = Join-Path $RecoveryRoot "Recovered-$timestamp-$id"
New-Item -ItemType Directory -Path $staging | Out-Null
try {
$before = @(Get-TreeManifest -Root $Source)
& "$env:SystemRoot\System32\robocopy.exe" `
$Source $staging /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NFL /NDL /NJH /NJS /NP | Out-Null
$robocopyExitCode = $LASTEXITCODE
if ($robocopyExitCode -ge 8) {
throw "robocopy failed with exit code $robocopyExitCode"
}
$after = @(Get-TreeManifest -Root $Source)
$copied = @(Get-TreeManifest -Root $staging)
Assert-TreeManifestsEqual -Expected $before -Actual $after -Message 'Legacy data changed while it was being copied; close every app using it and retry.'
Assert-TreeManifestsEqual -Expected $after -Actual $copied -Message 'The recovered copy does not exactly match the legacy data source.'
[IO.Directory]::Move($staging, $destination)
return $destination
} finally {
if (Test-Path -LiteralPath $staging) {
Remove-Item -LiteralPath $staging -Recurse -Force
}
}
}
function Write-AppModeAtomically {
param(
[Parameter(Mandatory = $true)][string]$UserDataDir,
[Parameter(Mandatory = $true)][string]$CustomDir
)
New-Item -ItemType Directory -Path $UserDataDir -Force | Out-Null
$target = Join-Path $UserDataDir 'app-mode.json'
$id = [Guid]::NewGuid().ToString('N')
$temporary = Join-Path $UserDataDir ".app-mode.$id.tmp"
$backup = Join-Path $UserDataDir "app-mode.pre-recovery.$id.bak"
$json = [ordered]@{
mode = 'portable'
portable_dir = $CustomDir
} | ConvertTo-Json
try {
[IO.File]::WriteAllText($temporary, $json, (New-Object Text.UTF8Encoding($false)))
if (Test-Path -LiteralPath $target -PathType Leaf) {
[IO.File]::Replace($temporary, $target, $backup, $true)
} else {
[IO.File]::Move($temporary, $target)
}
} finally {
if (Test-Path -LiteralPath $temporary) {
Remove-Item -LiteralPath $temporary -Force
}
}
}
function Invoke-LegacyRecovery {
param(
[Parameter(Mandatory = $true)][string[]]$InstallDirs,
[Parameter(Mandatory = $true)][string]$UserDataDir,
[Parameter(Mandatory = $true)][string]$RecoveryRoot,
[Parameter(Mandatory = $true)][string]$ProcessName,
[AllowEmptyString()][string]$ActiveConfigDir,
[AllowEmptyString()][string]$ActiveConfigManaged = '',
[string]$InstallerIdentitySafety = 'trusted-user',
[string[]]$RegisteredInstallDirs = @(),
[switch]$SkipProcessCheck
)
$potentialInstallDirs = @(Get-PotentialInstallDirs -InstallDirs $InstallDirs)
$existingInstallDirs = @(Get-ExistingInstallDirs -InstallDirs $potentialInstallDirs)
if ($existingInstallDirs.Count -eq 0) {
return $null
}
if ($InstallerIdentitySafety -eq 'untrusted-elevated' -and
@(Get-ExistingInstallDirs -InstallDirs $RegisteredInstallDirs).Count -gt 0) {
throw 'An existing registered installation cannot be upgraded from an elevated installer without the original user process. Run the installer normally (not as Administrator).'
}
if (-not $SkipProcessCheck) {
Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName
}
$source = Get-UnsafeLegacySource `
-InstallDirs $existingInstallDirs `
-UserDataDir $UserDataDir `
-ActiveConfigDir $ActiveConfigDir `
-ActiveConfigManaged $ActiveConfigManaged
Assert-NoUndiscoveredLegacySources -InstallDirs $existingInstallDirs -ActiveSource $source
if ([string]::IsNullOrWhiteSpace([string]$source)) {
return $null
}
if ($InstallerIdentitySafety -eq 'untrusted-elevated') {
throw 'Legacy data recovery was requested from an elevated installer without the original user process. Run the installer normally (not as Administrator) so recovery is written to the correct Windows user profile.'
}
foreach ($installDir in $potentialInstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $UserDataDir) {
throw "The mode metadata directory is inside an application install directory: $UserDataDir"
}
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $RecoveryRoot) {
throw "The recovery destination is inside an application install directory: $RecoveryRoot"
}
}
$destination = Copy-VerifiedTree -Source $source -RecoveryRoot $RecoveryRoot
$finalSource = @(Get-TreeManifest -Root $source)
$finalDestination = @(Get-TreeManifest -Root $destination)
Assert-TreeManifestsEqual `
-Expected $finalSource `
-Actual $finalDestination `
-Message 'Legacy data changed after the verified recovery copy was finalized; the installer stopped before removing the old version.'
if (-not $SkipProcessCheck) {
Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName
}
Write-AppModeAtomically -UserDataDir $UserDataDir -CustomDir $destination
return $destination
}
function Assert-SelfTest {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) {
throw "Self-test failed: $Message"
}
}
function Write-TestMode {
param(
[Parameter(Mandatory = $true)][string]$Dir,
[Parameter(Mandatory = $true)]$Value
)
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
$Value | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Dir 'app-mode.json') -Encoding UTF8
}
function Run-SelfTest {
$testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-storage-recovery-$([Guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Path $testRoot | Out-Null
try {
$install = Join-Path $testRoot 'old install'
$legacy = Join-Path $install 'CLAUDE_CONFIG_DIR'
$userData = Join-Path $testRoot 'app data'
$recovery = Join-Path $testRoot 'recovery'
New-Item -ItemType Directory -Path $legacy -Force | Out-Null
Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v1' -NoNewline
Write-TestMode -Dir $legacy -Value @{ mode = 'portable'; portable_dir = $null }
Write-TestMode -Dir $userData -Value @{ mode = 'default'; portable_dir = $null }
$first = Invoke-LegacyRecovery `
-InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery `
-ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck
Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $first 'settings.json') -PathType Leaf) -Message 'legacy default was not recovered'
Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'recovered content differs'
Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $legacy 'settings.json') -PathType Leaf) -Message 'source was modified'
$firstMode = Get-Content -LiteralPath (Join-Path $userData 'app-mode.json') -Raw | ConvertFrom-Json
Assert-SelfTest -Condition ($firstMode.mode -eq 'portable' -and $firstMode.portable_dir -eq $first) -Message 'recovery was not persisted as explicit custom mode'
Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v2' -NoNewline
$second = Invoke-LegacyRecovery `
-InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery `
-ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck
Assert-SelfTest -Condition ($second -ne $first) -Message 'retry reused an existing destination'
Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $second 'settings.json') -Raw) -eq 'legacy-v2') -Message 'retry did not capture the latest source'
Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'retry overwrote the first recovery'
$pointerInstall = Join-Path $testRoot 'pointer install'
$pointerLegacy = Join-Path $pointerInstall 'CLAUDE_CONFIG_DIR'
$pointerData = Join-Path $pointerInstall 'custom data'
$pointerUserData = Join-Path $testRoot 'pointer app data'
New-Item -ItemType Directory -Path $pointerData -Force | Out-Null
Set-Content -LiteralPath (Join-Path $pointerData '.claude.json') -Value 'pointer-data' -NoNewline
Write-TestMode -Dir $pointerLegacy -Value @{ mode = 'portable'; portable_dir = $pointerData }
$pointerRecovered = Invoke-LegacyRecovery `
-InstallDirs @($pointerInstall) -UserDataDir $pointerUserData -RecoveryRoot (Join-Path $testRoot 'pointer recovery') `
-ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck
Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $pointerRecovered '.claude.json') -Raw) -eq 'pointer-data') -Message 'legacy custom pointer was not recovered'
$managedInstall = Join-Path $testRoot 'managed env install'
$managedLegacy = Join-Path $managedInstall 'CLAUDE_CONFIG_DIR'
New-Item -ItemType Directory -Path $managedLegacy -Force | Out-Null
Set-Content -LiteralPath (Join-Path $managedLegacy 'settings.json') -Value 'managed-env' -NoNewline
$unmanagedFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'unmanaged app data') `
-RecoveryRoot (Join-Path $testRoot 'unmanaged recovery') -ProcessName $ProcessName `
-ActiveConfigDir $managedLegacy -ActiveConfigManaged '' -SkipProcessCheck | Out-Null
} catch {
$unmanagedFailed = $_.Exception.Message.Contains('managed outside Claude Code Haha')
}
Assert-SelfTest -Condition $unmanagedFailed -Message 'unsafe external CLAUDE_CONFIG_DIR did not fail closed'
$managedRecovered = Invoke-LegacyRecovery `
-InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'managed app data') `
-RecoveryRoot (Join-Path $testRoot 'managed recovery') -ProcessName $ProcessName `
-ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' -SkipProcessCheck
Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $managedRecovered 'settings.json') -Raw) -eq 'managed-env') -Message 'app-managed active environment was not recovered'
$elevatedFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'elevated app data') `
-RecoveryRoot (Join-Path $testRoot 'elevated recovery') -ProcessName $ProcessName `
-ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' `
-InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null
} catch {
$elevatedFailed = $_.Exception.Message.Contains('original user process')
}
Assert-SelfTest -Condition $elevatedFailed -Message 'untrusted elevated recovery did not fail closed'
$wrongIdentityInstall = Join-Path $testRoot 'registered shared install'
New-Item -ItemType Directory -Path (Join-Path $wrongIdentityInstall 'custom data') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $wrongIdentityInstall 'custom data\settings.json') -Value 'other-user-data' -NoNewline
$wrongIdentityFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($wrongIdentityInstall) -RegisteredInstallDirs @($wrongIdentityInstall) `
-UserDataDir (Join-Path $testRoot 'wrong identity app data') `
-RecoveryRoot (Join-Path $testRoot 'wrong identity recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null
} catch {
$wrongIdentityFailed = $_.Exception.Message.Contains('existing registered installation')
}
Assert-SelfTest -Condition $wrongIdentityFailed -Message 'untrusted elevated installer treated invisible user metadata as safe'
$futureInstall = Join-Path $testRoot 'future install'
$futureRecovery = Join-Path $futureInstall 'Recovered'
$futureFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($managedInstall, $futureInstall) `
-UserDataDir (Join-Path $testRoot 'future app data') -RecoveryRoot $futureRecovery `
-ProcessName $ProcessName -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' `
-SkipProcessCheck | Out-Null
} catch {
$futureFailed = $_.Exception.Message.Contains('recovery destination is inside an application install directory')
}
Assert-SelfTest -Condition $futureFailed -Message 'missing future install root did not protect its recovery subtree'
$sharedInstall = Join-Path $testRoot 'shared install'
$sharedLegacy = Join-Path $sharedInstall 'CLAUDE_CONFIG_DIR'
$activeExternal = Join-Path $testRoot 'active external'
New-Item -ItemType Directory -Path $sharedLegacy -Force | Out-Null
New-Item -ItemType Directory -Path $activeExternal -Force | Out-Null
Set-Content -LiteralPath (Join-Path $sharedLegacy 'settings.json') -Value 'shared-user-data' -NoNewline
$sharedFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($sharedInstall) -UserDataDir (Join-Path $testRoot 'shared app data') `
-RecoveryRoot (Join-Path $testRoot 'shared recovery') -ProcessName $ProcessName `
-ActiveConfigDir $activeExternal -ActiveConfigManaged '' -SkipProcessCheck | Out-Null
} catch {
$sharedFailed = $_.Exception.Message.Contains('another Windows user')
}
Assert-SelfTest -Condition $sharedFailed -Message 'external active config hid shared install-contained data'
$otherUserInstall = Join-Path $testRoot 'other user shared install'
$otherUserData = Join-Path $otherUserInstall 'B-data'
$currentUserMode = Join-Path $testRoot 'current user shared mode'
New-Item -ItemType Directory -Path $otherUserData -Force | Out-Null
Set-Content -LiteralPath (Join-Path $otherUserData 'settings.json') -Value 'other-windows-user' -NoNewline
Write-TestMode -Dir $otherUserData -Value @{ mode = 'portable'; portable_dir = $otherUserData }
Write-TestMode -Dir $currentUserMode -Value @{ mode = 'default'; portable_dir = $null }
$otherUserFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($otherUserInstall) -UserDataDir $currentUserMode `
-RecoveryRoot (Join-Path $testRoot 'other user recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -SkipProcessCheck | Out-Null
} catch {
$otherUserFailed = $_.Exception.Message.Contains('another Windows user')
}
Assert-SelfTest -Condition $otherUserFailed -Message 'another Windows user custom directory was not protected'
$managedExternalInstall = Join-Path $testRoot 'managed external install'
$managedExternalData = Join-Path $testRoot 'managed external data'
$managedExternalUserData = Join-Path $testRoot 'managed external app data'
New-Item -ItemType Directory -Path $managedExternalInstall -Force | Out-Null
New-Item -ItemType Directory -Path $managedExternalData -Force | Out-Null
Write-TestMode -Dir $managedExternalUserData -Value @{ mode = 'portable'; portable_dir = $managedExternalData }
$managedExternalResult = Invoke-LegacyRecovery `
-InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData `
-RecoveryRoot (Join-Path $testRoot 'managed external recovery') -ProcessName $ProcessName `
-ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck
Assert-SelfTest -Condition ($null -eq $managedExternalResult) -Message 'matching app-managed external custom mode was not accepted'
Set-Content -LiteralPath (Join-Path $managedExternalUserData 'app-mode.json') -Value '{broken-json' -NoNewline
$managedExternalInvalidFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData `
-RecoveryRoot (Join-Path $testRoot 'managed external invalid recovery') -ProcessName $ProcessName `
-ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null
} catch {
$managedExternalInvalidFailed = $_.Exception.Message.Contains('cannot be read safely')
}
Assert-SelfTest -Condition $managedExternalInvalidFailed -Message 'invalid metadata bypassed app-managed external mode validation'
$externalInstall = Join-Path $testRoot 'external install'
$externalLegacy = Join-Path $externalInstall 'CLAUDE_CONFIG_DIR'
$externalUserData = Join-Path $testRoot 'external app data'
$externalDir = Join-Path $testRoot 'external custom'
New-Item -ItemType Directory -Path $externalLegacy -Force | Out-Null
Set-Content -LiteralPath (Join-Path $externalLegacy 'settings.json') -Value 'inactive-legacy' -NoNewline
New-Item -ItemType Directory -Path $externalDir -Force | Out-Null
Write-TestMode -Dir $externalUserData -Value @{ mode = 'portable'; portable_dir = $externalDir }
$externalResult = Invoke-LegacyRecovery `
-InstallDirs @($externalInstall) -UserDataDir $externalUserData -RecoveryRoot (Join-Path $testRoot 'external recovery') `
-ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck
Assert-SelfTest -Condition ($null -eq $externalResult) -Message 'external custom data was unnecessarily migrated'
$secondInstall = Join-Path $testRoot 'second install'
$secondLegacy = Join-Path $secondInstall 'CLAUDE_CONFIG_DIR'
New-Item -ItemType Directory -Path $secondLegacy -Force | Out-Null
Set-Content -LiteralPath (Join-Path $secondLegacy 'settings.json') -Value 'second-source' -NoNewline
Write-TestMode -Dir $secondLegacy -Value @{ mode = 'portable'; portable_dir = $null }
$multipleFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($install, $secondInstall) -UserDataDir (Join-Path $testRoot 'multiple app data') `
-RecoveryRoot (Join-Path $testRoot 'multiple recovery') -ProcessName $ProcessName `
-ActiveConfigDir $legacy -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null
} catch {
$multipleFailed = $_.Exception.Message.Contains('Multiple distinct legacy data sources')
}
Assert-SelfTest -Condition $multipleFailed -Message 'active environment hid an ambiguous dual-install source'
$invalidInstall = Join-Path $testRoot 'invalid mode install'
$invalidUserData = Join-Path $testRoot 'invalid mode app data'
New-Item -ItemType Directory -Path $invalidInstall -Force | Out-Null
New-Item -ItemType Directory -Path $invalidUserData -Force | Out-Null
Set-Content -LiteralPath (Join-Path $invalidUserData 'app-mode.json') -Value '{broken-json' -NoNewline
$invalidFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($invalidInstall) -UserDataDir $invalidUserData `
-RecoveryRoot (Join-Path $testRoot 'invalid recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -SkipProcessCheck | Out-Null
} catch {
$invalidFailed = $_.Exception.Message.Contains('cannot be read safely')
}
Assert-SelfTest -Condition $invalidFailed -Message 'invalid existing mode metadata was treated as absent'
$junctionInstall = Join-Path $testRoot 'junction install'
$junctionTarget = Join-Path $testRoot 'junction external target'
$junctionLegacy = Join-Path $junctionInstall 'CLAUDE_CONFIG_DIR'
New-Item -ItemType Directory -Path $junctionInstall -Force | Out-Null
New-Item -ItemType Directory -Path $junctionTarget -Force | Out-Null
Set-Content -LiteralPath (Join-Path $junctionTarget 'settings.json') -Value 'junction-data' -NoNewline
New-Item -ItemType Junction -Path $junctionLegacy -Target $junctionTarget | Out-Null
$junctionFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($junctionInstall) -UserDataDir (Join-Path $testRoot 'junction app data') `
-RecoveryRoot (Join-Path $testRoot 'junction recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -SkipProcessCheck | Out-Null
} catch {
$junctionFailed = $_.Exception.Message.Contains('reparse point')
}
Assert-SelfTest -Condition $junctionFailed -Message 'install-contained junction source was silently skipped'
[Console]::Out.WriteLine('Legacy install data recovery self-test passed.')
} finally {
Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
try {
if ($SelfTest) {
Run-SelfTest
exit 0
}
$result = Invoke-LegacyRecovery `
-InstallDirs @($PerUserInstallDir, $PerMachineInstallDir, $CandidateInstallDir) `
-UserDataDir $UserDataDir `
-RecoveryRoot $RecoveryRoot `
-ProcessName $ProcessName `
-ActiveConfigDir $ActiveConfigDir `
-ActiveConfigManaged $ActiveConfigManaged `
-InstallerIdentitySafety $InstallerIdentitySafety `
-RegisteredInstallDirs @($PerUserInstallDir, $PerMachineInstallDir) `
-SkipProcessCheck:$SkipProcessCheck
if (-not [string]::IsNullOrWhiteSpace([string]$result)) {
[Console]::Out.WriteLine("Recovered legacy data to $result")
}
exit 0
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 20
}