Files
jeremy 20a6d93955 fix(install): strip UTF-8 BOM from PowerShell scripts
'irm <url> | iex' on Windows PowerShell 5.1 does not honor the BOM in
the response body, so the stray EF BB BF bytes corrupted the leading
'<#' help comment and the whole script failed to parse with
'Missing ( after If in if statement'. Remove the BOM from install.ps1
and uninstall.ps1, and make check_powershell_syntax.ps1 fail on any
BOM so CI prevents a regression.

Fixes #503 (install portion)
2026-07-18 16:10:47 -07:00

420 lines
15 KiB
PowerShell

<#
.SYNOPSIS
Uninstall jcode on Windows.
.DESCRIPTION
Removes the per-user launcher at %LOCALAPPDATA%\jcode\bin\jcode.exe,
installed build binaries, and the jcode launcher directory from the user PATH.
By default user data under %USERPROFILE%\.jcode is kept.
One-liner uninstall:
irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/uninstall.ps1 | iex
.PARAMETER InstallDir
Override the launcher directory (default: $env:LOCALAPPDATA\jcode\bin)
.PARAMETER Purge
Also delete user data in $env:JCODE_HOME or %USERPROFILE%\.jcode.
.PARAMETER DryRun
Print what would be removed without deleting anything.
.PARAMETER Yes
Skip the confirmation prompt.
#>
param(
[string]$InstallDir,
[switch]$Purge,
[switch]$DryRun,
[switch]$Yes
)
$ErrorActionPreference = 'Stop'
function Write-Info($msg) { Write-Host $msg -ForegroundColor Blue }
function Write-Err($msg) { throw "error: $msg" }
function Write-Warn($msg) { Write-Host "warning: $msg" -ForegroundColor Yellow }
function Get-JcodeLocalAppDataDir {
if ($env:LOCALAPPDATA) { return $env:LOCALAPPDATA }
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
if ($localAppData) { return $localAppData }
if ($env:USERPROFILE) { return (Join-Path $env:USERPROFILE "AppData\Local") }
return (Join-Path ([Environment]::GetFolderPath("UserProfile")) "AppData\Local")
}
function Get-DefaultJcodeInstallDir {
return (Join-Path (Get-JcodeLocalAppDataDir) "jcode\bin")
}
function Get-JcodeRoamingAppDataDir {
if ($env:APPDATA) { return $env:APPDATA }
$appData = [Environment]::GetFolderPath([Environment+SpecialFolder]::ApplicationData)
if ($appData) { return $appData }
if ($env:USERPROFILE) { return (Join-Path $env:USERPROFILE "AppData\Roaming") }
return (Join-Path ([Environment]::GetFolderPath("UserProfile")) "AppData\Roaming")
}
function Get-JcodeStartupShortcutPath {
return (Join-Path (Get-JcodeRoamingAppDataDir) "Microsoft\Windows\Start Menu\Programs\Startup\jcode-hotkey.lnk")
}
function Get-JcodeHotkeyArtifactPaths([string]$UserDataDir) {
$hotkeyDir = Join-Path $UserDataDir "hotkey"
return @(
(Join-Path $hotkeyDir "jcode-hotkey.ps1"),
(Join-Path $hotkeyDir "jcode-hotkey-launcher.vbs"),
(Join-Path $hotkeyDir "jcode-hotkey-shortcut.ps1")
)
}
function Clear-JcodeHotkeySetupState([string]$UserDataDir) {
$setupHintsPath = Join-Path $UserDataDir "setup_hints.json"
if (-not (Test-Path -LiteralPath $setupHintsPath)) { return }
try {
$state = Get-Content -LiteralPath $setupHintsPath -Raw | ConvertFrom-Json -ErrorAction Stop
foreach ($property in @(
@{ Name = "hotkey_configured"; Value = $false },
@{ Name = "hotkey_dismissed"; Value = $true }
)) {
if ($state.PSObject.Properties.Name -contains $property.Name) {
$state.($property.Name) = $property.Value
} else {
$state | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value
}
}
$state | ConvertTo-Json | Set-Content -LiteralPath $setupHintsPath -Encoding UTF8
} catch {
Write-Warn "Could not update hotkey setup state in $setupHintsPath"
}
}
function ConvertTo-JcodePathKey([string]$PathValue) {
if (-not $PathValue) { return "" }
$clean = [Environment]::ExpandEnvironmentVariables($PathValue.Trim().Trim('"'))
if (-not $clean) { return "" }
try { $clean = [System.IO.Path]::GetFullPath($clean) } catch {}
$clean = $clean.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
return $clean.ToUpperInvariant()
}
function Test-JcodeSafePurgePath([string]$PathValue) {
$pathKey = ConvertTo-JcodePathKey $PathValue
if (-not $pathKey) { return $false }
try {
$fullPath = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($PathValue.Trim().Trim('"')))
$rootKey = ConvertTo-JcodePathKey ([System.IO.Path]::GetPathRoot($fullPath))
$leafName = [System.IO.Path]::GetFileName($fullPath.TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
))
} catch {
return $false
}
if ($pathKey -eq $rootKey -or $leafName -notmatch '(?i)^\.?jcode(?:[-_ ].*)?$') {
return $false
}
$separator = [string][System.IO.Path]::DirectorySeparatorChar
foreach ($protectedPath in @(
$env:USERPROFILE,
$env:HOME,
$env:LOCALAPPDATA,
$env:APPDATA,
[Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
)) {
$protectedKey = ConvertTo-JcodePathKey $protectedPath
if (-not $protectedKey) { continue }
if ($pathKey -eq $protectedKey -or $protectedKey.StartsWith($pathKey + $separator, [System.StringComparison]::OrdinalIgnoreCase)) {
return $false
}
}
return $true
}
function Test-JcodeManagedExecutablePath([string]$ExecutablePath, [string]$LauncherPath, [string]$BuildsDir) {
$executableKey = ConvertTo-JcodePathKey $ExecutablePath
$launcherKey = ConvertTo-JcodePathKey $LauncherPath
$buildsKey = ConvertTo-JcodePathKey $BuildsDir
if (-not $executableKey) { return $false }
if ($launcherKey -and $executableKey -eq $launcherKey) { return $true }
# A live upgrade may rename the loaded stable launcher before replacing it.
# Treat only that tightly-scoped backup pattern in the launcher directory as
# managed so uninstall can stop and remove it without touching other tools.
$launcherDirKey = ConvertTo-JcodePathKey (Split-Path -Parent $LauncherPath)
$executableDirKey = ConvertTo-JcodePathKey (Split-Path -Parent $ExecutablePath)
$executableName = Split-Path -Leaf $ExecutablePath
if ($launcherDirKey -and $executableDirKey -eq $launcherDirKey -and $executableName -like '.jcode-launcher-old-*.exe') {
return $true
}
$separator = [string][System.IO.Path]::DirectorySeparatorChar
return [bool]($buildsKey -and $executableKey.StartsWith($buildsKey + $separator, [System.StringComparison]::OrdinalIgnoreCase))
}
function Split-JcodePathList([string]$PathValue) {
if (-not $PathValue) { return @() }
$entries = @()
foreach ($entry in ($PathValue -split ';')) {
$clean = $entry.Trim().Trim('"')
if ($clean) { $entries += $clean }
}
return $entries
}
function Join-JcodePathList([string[]]$Entries) {
if (-not $Entries -or $Entries.Count -eq 0) { return "" }
return ($Entries -join ';')
}
function Get-JcodeManagedPathKeys([string]$InstallDir) {
$keys = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($candidate in @($InstallDir, (Get-DefaultJcodeInstallDir))) {
$key = ConvertTo-JcodePathKey $candidate
if ($key) { [void]$keys.Add($key) }
}
return $keys
}
function Resolve-JcodePathRemoval {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
[AllowNull()][string]$CurrentPath
)
$managedKeys = Get-JcodeManagedPathKeys -InstallDir $InstallDir
$nextEntries = @()
$removedManaged = 0
foreach ($entry in (Split-JcodePathList $CurrentPath)) {
$key = ConvertTo-JcodePathKey $entry
if (-not $key) { continue }
if ($managedKeys.Contains($key)) {
$removedManaged += 1
continue
}
$nextEntries += $entry
}
$nextPath = Join-JcodePathList $nextEntries
return [pscustomobject]@{
Path = $nextPath
Changed = ($nextPath -ne ([string]$CurrentPath))
RemovedManagedEntries = $removedManaged
InstallDir = $InstallDir
}
}
function Send-JcodeEnvironmentChangedBroadcast {
if ($env:JCODE_DISABLE_ENV_BROADCAST -eq "1") { return $false }
if (-not ("Jcode.EnvironmentBroadcast" -as [type])) {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
namespace Jcode {
public static class EnvironmentBroadcast {
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr SendMessageTimeout(
IntPtr hWnd,
UInt32 Msg,
UIntPtr wParam,
string lParam,
UInt32 fuFlags,
UInt32 uTimeout,
out UIntPtr lpdwResult);
}
}
"@
}
$result = [UIntPtr]::Zero
[Jcode.EnvironmentBroadcast]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$result) | Out-Null
return $true
}
function Remove-JcodeUserPath {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
[AllowNull()][string]$CurrentPath,
[scriptblock]$SetUserPathAction,
[scriptblock]$BroadcastAction,
[bool]$Broadcast = $true
)
if (-not $PSBoundParameters.ContainsKey('CurrentPath')) {
$CurrentPath = [Environment]::GetEnvironmentVariable("Path", "User")
}
$update = Resolve-JcodePathRemoval -InstallDir $InstallDir -CurrentPath $CurrentPath
$broadcasted = $false
if ($update.Changed) {
if ($SetUserPathAction) {
& $SetUserPathAction $update.Path
} else {
[Environment]::SetEnvironmentVariable("Path", $update.Path, "User")
}
if ($Broadcast) {
if ($BroadcastAction) { & $BroadcastAction | Out-Null } else { Send-JcodeEnvironmentChangedBroadcast | Out-Null }
$broadcasted = $true
}
}
$update | Add-Member -NotePropertyName Broadcasted -NotePropertyValue $broadcasted
return $update
}
function Invoke-JcodeUninstall {
param(
[string]$InstallDir,
[switch]$Purge,
[switch]$DryRun,
[switch]$Yes
)
if (-not $InstallDir) { $InstallDir = Get-DefaultJcodeInstallDir }
$localJcodeRoot = Join-Path (Get-JcodeLocalAppDataDir) "jcode"
$launcherPath = Join-Path $InstallDir "jcode.exe"
$buildsDir = Join-Path $localJcodeRoot "builds"
$userDataDir = if ($env:JCODE_HOME) {
$env:JCODE_HOME
} elseif ($env:USERPROFILE) {
Join-Path $env:USERPROFILE ".jcode"
} else {
Join-Path ([Environment]::GetFolderPath("UserProfile")) ".jcode"
}
$startupShortcutPath = Get-JcodeStartupShortcutPath
$hotkeyArtifactPaths = @(Get-JcodeHotkeyArtifactPaths -UserDataDir $userDataDir)
$launcherBackupPaths = if (Test-Path -LiteralPath $InstallDir) {
@(Get-ChildItem -LiteralPath $InstallDir -Filter '.jcode-launcher-old-*.exe' -File -Force -ErrorAction SilentlyContinue |
ForEach-Object { $_.FullName })
} else {
@()
}
if ($Purge -and -not (Test-JcodeSafePurgePath $userDataDir)) {
Write-Err "Refusing to purge unsafe JCODE_HOME path '$userDataDir'. Use a dedicated .jcode or jcode-* directory."
}
$targets = @()
if (Test-Path -LiteralPath $launcherPath) { $targets += "$launcherPath (launcher)" }
foreach ($path in $launcherBackupPaths) { $targets += "$path (previous live-upgrade launcher)" }
if (Test-Path -LiteralPath $buildsDir) { $targets += "$buildsDir (installed binaries)" }
if (Test-Path -LiteralPath $startupShortcutPath) { $targets += "$startupShortcutPath (launch-hotkey startup shortcut)" }
foreach ($path in $hotkeyArtifactPaths) {
if (Test-Path -LiteralPath $path) { $targets += "$path (launch-hotkey artifact)" }
}
if ($Purge -and (Test-Path -LiteralPath $userDataDir)) { $targets += "$userDataDir (user data)" }
$userPathPreview = Resolve-JcodePathRemoval -InstallDir $InstallDir -CurrentPath ([Environment]::GetEnvironmentVariable("Path", "User"))
if ($userPathPreview.RemovedManagedEntries -gt 0) {
$targets += "$InstallDir (user PATH entry)"
}
if ($targets.Count -eq 0) {
Write-Info "Nothing to uninstall: no jcode installation found."
return 0
}
Write-Info "The following will be removed:"
foreach ($target in $targets) { Write-Host " - $target" }
if (-not $Purge) {
Write-Warn "User data in $userDataDir is kept. Run with -Purge for a full wipe."
}
if ($DryRun) {
Write-Info "Dry run: nothing was deleted."
return 0
}
if (-not $Yes) {
$reply = Read-Host "Proceed? [y/N]"
if ($reply -notin @("y", "Y", "yes", "YES")) {
Write-Info "Aborted."
return 1
}
}
try {
$managedProcessIds = @(Get-CimInstance Win32_Process -Filter "Name = 'jcode.exe'" -ErrorAction SilentlyContinue |
Where-Object { Test-JcodeManagedExecutablePath -ExecutablePath $_.ExecutablePath -LauncherPath $launcherPath -BuildsDir $buildsDir } |
ForEach-Object { $_.ProcessId })
foreach ($processId in $managedProcessIds) {
$process = Get-Process -Id $processId -ErrorAction SilentlyContinue
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
if ($process) {
try { [void]$process.WaitForExit(10000) } catch {}
}
}
} catch {}
if (Test-Path -LiteralPath $startupShortcutPath) {
Remove-Item -LiteralPath $startupShortcutPath -Force
Write-Info "Removed $startupShortcutPath"
}
foreach ($path in $hotkeyArtifactPaths) {
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force
Write-Info "Removed $path"
}
}
if (-not $Purge) {
$hotkeyDir = Join-Path $userDataDir "hotkey"
if (Test-Path -LiteralPath $hotkeyDir) {
Remove-Item -LiteralPath $hotkeyDir -Force -ErrorAction SilentlyContinue
}
Clear-JcodeHotkeySetupState -UserDataDir $userDataDir
}
if (Test-Path -LiteralPath $launcherPath) {
Remove-Item -LiteralPath $launcherPath -Force
Write-Info "Removed $launcherPath"
}
foreach ($path in $launcherBackupPaths) {
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force
Write-Info "Removed $path"
}
}
if (Test-Path -LiteralPath $InstallDir) {
try { Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue } catch {}
}
if ($Purge) {
foreach ($path in @($localJcodeRoot, $userDataDir)) {
if ($path -and (Test-Path -LiteralPath $path)) {
Remove-Item -LiteralPath $path -Recurse -Force
Write-Info "Removed $path"
}
}
} elseif (Test-Path -LiteralPath $buildsDir) {
Remove-Item -LiteralPath $buildsDir -Recurse -Force
Write-Info "Removed $buildsDir"
}
$pathUpdate = Remove-JcodeUserPath -InstallDir $InstallDir
if ($pathUpdate.Changed) {
Write-Info "Removed $($pathUpdate.RemovedManagedEntries) jcode entr$(if ($pathUpdate.RemovedManagedEntries -eq 1) { 'y' } else { 'ies' }) from user PATH"
}
Write-Info "jcode uninstalled."
Write-Info "Reinstall with: irm https://jcode.sh/install.ps1 | iex"
return 0
}
if ($env:JCODE_UNINSTALL_PS1_IMPORT_ONLY -ne "1") {
$exitCode = Invoke-JcodeUninstall -InstallDir $InstallDir -Purge:$Purge -DryRun:$DryRun -Yes:$Yes
if ($null -ne $exitCode -and [int]$exitCode -ne 0) {
if ($MyInvocation.MyCommand.Path) { exit ([int]$exitCode) }
$global:LASTEXITCODE = [int]$exitCode
}
}