2026-01-28 14:57:30 +00:00
|
|
|
# Install the Core CLI (Windows)
|
|
|
|
|
# Run: .\scripts\install-core.ps1
|
2026-02-01 00:29:32 +11:00
|
|
|
#
|
2026-02-01 00:54:45 +11:00
|
|
|
# REQUIREMENTS:
|
|
|
|
|
# - PowerShell 4.0 or later
|
|
|
|
|
# - Windows 10/11 or Windows Server 2016+
|
|
|
|
|
# - 100MB free disk space
|
|
|
|
|
#
|
2026-02-01 00:29:32 +11:00
|
|
|
# SECURITY CONTROLS:
|
|
|
|
|
# - Version pinning prevents supply chain attacks via tag manipulation
|
|
|
|
|
# - SHA256 hash verification ensures binary integrity
|
|
|
|
|
# - Path validation prevents LOCALAPPDATA redirection attacks
|
|
|
|
|
# - Symlink detection prevents directory traversal attacks
|
|
|
|
|
# - Restrictive ACLs on temp directories prevent local privilege escalation
|
|
|
|
|
# - GPG signature verification (when available) ensures code authenticity
|
|
|
|
|
#
|
|
|
|
|
# KNOWN LIMITATIONS:
|
|
|
|
|
# - Checksums are fetched from same origin as binaries (consider separate trust root)
|
|
|
|
|
# - No TLS certificate pinning (relies on system CA store)
|
2026-01-28 14:57:30 +00:00
|
|
|
|
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
|
|
2026-02-01 00:54:45 +11:00
|
|
|
# Check PowerShell version (4.0+ required for Get-FileHash and other features)
|
|
|
|
|
if ($PSVersionTable.PSVersion.Major -lt 4) {
|
|
|
|
|
Write-Host "[ERROR] PowerShell 4.0 or later is required. Current version: $($PSVersionTable.PSVersion)" -ForegroundColor Red
|
|
|
|
|
exit 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:00:23 +11:00
|
|
|
$Repo = "host-uk/core"
|
2026-02-01 00:54:45 +11:00
|
|
|
$MinDiskSpaceMB = 100 # Minimum required disk space in MB
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-02 02:36:18 +00:00
|
|
|
# Resolve latest release version from GitHub API
|
|
|
|
|
function Get-LatestVersion {
|
|
|
|
|
try {
|
|
|
|
|
if (Test-Command gh) {
|
|
|
|
|
$version = gh release view --repo $Repo --json tagName -q '.tagName' 2>$null
|
|
|
|
|
if ($version) { return $version }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Fallback to GitHub API
|
|
|
|
|
$response = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
|
|
|
|
|
if ($response.tag_name) { return $response.tag_name }
|
|
|
|
|
} catch {
|
|
|
|
|
Write-Warn "Could not determine latest version, using default branch"
|
|
|
|
|
}
|
|
|
|
|
return $null
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 14:57:30 +00:00
|
|
|
function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Green }
|
|
|
|
|
function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow }
|
|
|
|
|
function Write-Err { Write-Host "[ERROR] $args" -ForegroundColor Red; exit 1 }
|
|
|
|
|
|
|
|
|
|
function Test-Command($cmd) {
|
|
|
|
|
return [bool](Get-Command $cmd -ErrorAction SilentlyContinue)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:54:45 +11:00
|
|
|
# Check available disk space
|
|
|
|
|
function Test-DiskSpace {
|
|
|
|
|
param([string]$Path)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
# Get the drive from the path
|
|
|
|
|
$drive = [System.IO.Path]::GetPathRoot($Path)
|
|
|
|
|
if ([string]::IsNullOrEmpty($drive)) {
|
|
|
|
|
$drive = [System.IO.Path]::GetPathRoot($env:LOCALAPPDATA)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$driveInfo = Get-PSDrive -Name $drive.Substring(0, 1) -ErrorAction Stop
|
|
|
|
|
$freeSpaceMB = [math]::Round($driveInfo.Free / 1MB, 2)
|
|
|
|
|
|
|
|
|
|
if ($freeSpaceMB -lt $MinDiskSpaceMB) {
|
|
|
|
|
Write-Err "Insufficient disk space. Need at least ${MinDiskSpaceMB}MB, but only ${freeSpaceMB}MB available on drive $drive"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Write-Info "Disk space check passed (${freeSpaceMB}MB available)"
|
|
|
|
|
return $true
|
|
|
|
|
} catch {
|
|
|
|
|
Write-Warn "Could not verify disk space: $($_.Exception.Message)"
|
|
|
|
|
return $true # Continue anyway if check fails
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:20:37 +11:00
|
|
|
# Validate and get secure install directory
|
|
|
|
|
function Get-SecureInstallDir {
|
|
|
|
|
# Validate LOCALAPPDATA is within user profile
|
|
|
|
|
$localAppData = $env:LOCALAPPDATA
|
|
|
|
|
$userProfile = $env:USERPROFILE
|
|
|
|
|
|
|
|
|
|
if ([string]::IsNullOrEmpty($localAppData)) {
|
|
|
|
|
Write-Err "LOCALAPPDATA environment variable is not set"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ([string]::IsNullOrEmpty($userProfile)) {
|
|
|
|
|
Write-Err "USERPROFILE environment variable is not set"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Resolve to absolute paths
|
|
|
|
|
$resolvedLocalAppData = [System.IO.Path]::GetFullPath($localAppData)
|
|
|
|
|
$resolvedUserProfile = [System.IO.Path]::GetFullPath($userProfile)
|
|
|
|
|
|
|
|
|
|
# Ensure LOCALAPPDATA is under user profile (prevent redirection attacks)
|
|
|
|
|
if (-not $resolvedLocalAppData.StartsWith($resolvedUserProfile, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
|
|
|
Write-Err "LOCALAPPDATA ($resolvedLocalAppData) is not within user profile ($resolvedUserProfile). Possible path manipulation detected."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$installDir = Join-Path $resolvedLocalAppData "Programs\core"
|
|
|
|
|
|
|
|
|
|
# Validate the resolved path doesn't contain suspicious patterns
|
|
|
|
|
if ($installDir -match '\.\.' -or $installDir -match '[\x00-\x1f]') {
|
|
|
|
|
Write-Err "Invalid characters detected in install path"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $installDir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$InstallDir = Get-SecureInstallDir
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Verify directory is safe for writing (not a symlink, correct permissions)
|
|
|
|
|
function Test-SecureDirectory {
|
|
|
|
|
param([string]$Path)
|
|
|
|
|
|
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
|
|
|
return $true # Directory doesn't exist yet, will be created
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:46:46 +11:00
|
|
|
# Primary check: use fsutil for reliable reparse point detection (matches batch script)
|
|
|
|
|
$fsutilResult = & fsutil reparsepoint query $Path 2>&1
|
|
|
|
|
if ($LASTEXITCODE -eq 0) {
|
|
|
|
|
Write-Err "Directory '$Path' is a reparse point (symlink or junction). Possible symlink attack detected."
|
|
|
|
|
}
|
2026-02-01 00:29:32 +11:00
|
|
|
|
2026-02-01 00:46:46 +11:00
|
|
|
# Fallback: check .NET attributes
|
|
|
|
|
$dirInfo = Get-Item $Path -Force
|
2026-02-01 00:29:32 +11:00
|
|
|
if ($dirInfo.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
|
|
|
|
|
Write-Err "Directory '$Path' is a symbolic link or junction. Possible symlink attack detected."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:20:37 +11:00
|
|
|
# Verify SHA256 hash of downloaded file
|
|
|
|
|
function Test-FileHash {
|
|
|
|
|
param(
|
|
|
|
|
[string]$FilePath,
|
|
|
|
|
[string]$ExpectedHash
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$actualHash = (Get-FileHash -Path $FilePath -Algorithm SHA256).Hash
|
|
|
|
|
if ($actualHash -ne $ExpectedHash) {
|
|
|
|
|
Remove-Item -Path $FilePath -Force -ErrorAction SilentlyContinue
|
|
|
|
|
Write-Err "Hash verification failed! Expected: $ExpectedHash, Got: $actualHash. The downloaded file may be corrupted or tampered with."
|
|
|
|
|
}
|
2026-02-01 00:29:32 +11:00
|
|
|
Write-Info "Hash verification passed (SHA256: $($actualHash.Substring(0,16))...)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Create directory with atomic check-and-create to minimize TOCTOU window
|
|
|
|
|
function New-SecureDirectory {
|
|
|
|
|
param([string]$Path)
|
|
|
|
|
|
|
|
|
|
# Check parent directory for symlinks first
|
|
|
|
|
$parent = Split-Path $Path -Parent
|
|
|
|
|
if ($parent -and (Test-Path $parent)) {
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = Test-SecureDirectory -Path $parent
|
2026-02-01 00:29:32 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Create directory
|
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
|
|
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Immediately verify it's not a symlink (minimize TOCTOU window)
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = Test-SecureDirectory -Path $Path
|
2026-02-01 00:29:32 +11:00
|
|
|
|
|
|
|
|
return $Path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Set restrictive ACL on directory (owner-only access)
|
|
|
|
|
# This is REQUIRED for temp directories - failure is fatal
|
|
|
|
|
function Set-SecureDirectoryAcl {
|
|
|
|
|
param(
|
|
|
|
|
[string]$Path,
|
|
|
|
|
[switch]$Required
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
$maxRetries = 3
|
|
|
|
|
$retryCount = 0
|
|
|
|
|
|
|
|
|
|
while ($retryCount -lt $maxRetries) {
|
|
|
|
|
try {
|
|
|
|
|
$acl = Get-Acl $Path
|
|
|
|
|
|
|
|
|
|
# Disable inheritance and remove inherited rules
|
|
|
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
|
|
|
|
|
|
|
|
# Clear existing rules
|
|
|
|
|
$acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) } | Out-Null
|
|
|
|
|
|
|
|
|
|
# Add full control for current user only
|
|
|
|
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
2026-02-01 01:17:10 +00:00
|
|
|
# Pre-calculate flags to avoid -bor compatibility issues across PowerShell versions
|
|
|
|
|
$inheritFlags = [System.Security.AccessControl.InheritanceFlags]([int][System.Security.AccessControl.InheritanceFlags]::ContainerInherit + [int][System.Security.AccessControl.InheritanceFlags]::ObjectInherit)
|
2026-02-01 00:29:32 +11:00
|
|
|
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
|
|
|
$currentUser,
|
|
|
|
|
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
2026-02-01 01:17:10 +00:00
|
|
|
$inheritFlags,
|
2026-02-01 00:29:32 +11:00
|
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
|
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
|
|
|
)
|
|
|
|
|
$acl.AddAccessRule($rule)
|
|
|
|
|
|
|
|
|
|
Set-Acl -Path $Path -AclObject $acl
|
|
|
|
|
Write-Info "Set restrictive permissions on $Path"
|
|
|
|
|
return $true
|
|
|
|
|
} catch {
|
|
|
|
|
$retryCount++
|
|
|
|
|
if ($retryCount -ge $maxRetries) {
|
|
|
|
|
if ($Required) {
|
|
|
|
|
Write-Err "SECURITY: Failed to set restrictive ACL on '$Path' after $maxRetries attempts: $($_.Exception.Message)"
|
|
|
|
|
} else {
|
|
|
|
|
Write-Warn "Could not set restrictive ACL on '$Path': $($_.Exception.Message)"
|
|
|
|
|
return $false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Start-Sleep -Milliseconds 100
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-01 00:20:37 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Download pre-built binary with integrity verification
|
2026-01-28 14:57:30 +00:00
|
|
|
function Download-Binary {
|
|
|
|
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
|
2026-02-01 06:21:18 +00:00
|
|
|
$binaryUrl = "https://github.com/$Repo/releases/latest/download/core-windows-$arch.exe"
|
|
|
|
|
$checksumUrl = "https://github.com/$Repo/releases/latest/download/checksums.txt"
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 06:21:18 +00:00
|
|
|
Write-Info "Attempting to download pre-built binary..."
|
2026-02-01 00:20:37 +11:00
|
|
|
Write-Info "URL: $binaryUrl"
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 00:54:45 +11:00
|
|
|
# Track temp file for cleanup
|
|
|
|
|
$tempExe = $null
|
|
|
|
|
|
2026-01-28 14:57:30 +00:00
|
|
|
try {
|
2026-02-01 01:17:10 +00:00
|
|
|
# Create and verify install directory (suppress output to avoid polluting return value)
|
|
|
|
|
$null = New-SecureDirectory -Path $InstallDir
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Use a temp file in the same directory (same filesystem for atomic move)
|
|
|
|
|
$tempExe = Join-Path $InstallDir "core.exe.tmp.$([System.Guid]::NewGuid().ToString('N').Substring(0,8))"
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Download the binary to temp location
|
2026-02-01 00:20:37 +11:00
|
|
|
Invoke-WebRequest -Uri $binaryUrl -OutFile $tempExe -UseBasicParsing
|
|
|
|
|
|
|
|
|
|
# Download and parse checksums
|
|
|
|
|
Write-Info "Verifying download integrity..."
|
|
|
|
|
$checksumContent = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing | Select-Object -ExpandProperty Content
|
|
|
|
|
|
|
|
|
|
# Parse checksum file (format: "hash filename")
|
|
|
|
|
$expectedHash = $null
|
|
|
|
|
$checksumLines = $checksumContent -split "`n"
|
|
|
|
|
foreach ($line in $checksumLines) {
|
|
|
|
|
if ($line -match "^([a-fA-F0-9]{64})\s+.*core-windows-$arch\.exe") {
|
|
|
|
|
$expectedHash = $matches[1].ToUpper()
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ([string]::IsNullOrEmpty($expectedHash)) {
|
|
|
|
|
Write-Err "Could not find checksum for core-windows-$arch.exe in checksums.txt"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Verify hash BEFORE any move operation
|
2026-02-01 00:20:37 +11:00
|
|
|
Test-FileHash -FilePath $tempExe -ExpectedHash $expectedHash
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Re-verify directory hasn't been replaced with symlink (reduce TOCTOU window)
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = Test-SecureDirectory -Path $InstallDir
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Atomic move to final location (same filesystem)
|
|
|
|
|
$finalPath = Join-Path $InstallDir "core.exe"
|
|
|
|
|
Move-Item -Path $tempExe -Destination $finalPath -Force
|
2026-02-01 00:54:45 +11:00
|
|
|
$tempExe = $null # Clear so finally block doesn't try to delete
|
2026-02-01 00:29:32 +11:00
|
|
|
|
|
|
|
|
Write-Info "Downloaded and verified: $finalPath"
|
2026-01-28 14:57:30 +00:00
|
|
|
return $true
|
2026-02-01 00:20:37 +11:00
|
|
|
} catch [System.Net.WebException] {
|
|
|
|
|
Write-Warn "Network error during download: $($_.Exception.Message)"
|
|
|
|
|
Write-Warn "Will attempt to build from source"
|
|
|
|
|
return $false
|
|
|
|
|
} catch [System.IO.IOException] {
|
|
|
|
|
Write-Warn "File system error: $($_.Exception.Message)"
|
|
|
|
|
Write-Warn "Will attempt to build from source"
|
|
|
|
|
return $false
|
2026-01-28 14:57:30 +00:00
|
|
|
} catch {
|
2026-02-01 00:20:37 +11:00
|
|
|
Write-Warn "Download failed: $($_.Exception.Message)"
|
|
|
|
|
Write-Warn "Will attempt to build from source"
|
2026-01-28 14:57:30 +00:00
|
|
|
return $false
|
2026-02-01 00:54:45 +11:00
|
|
|
} finally {
|
|
|
|
|
# Clean up temp file if it exists (handles Ctrl+C and other interruptions)
|
|
|
|
|
if ($tempExe -and (Test-Path $tempExe -ErrorAction SilentlyContinue)) {
|
|
|
|
|
Remove-Item -Path $tempExe -Force -ErrorAction SilentlyContinue
|
|
|
|
|
}
|
2026-01-28 14:57:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Verify GPG signature on git tag (if gpg is available)
|
|
|
|
|
function Test-GitTagSignature {
|
|
|
|
|
param(
|
|
|
|
|
[string]$RepoPath,
|
|
|
|
|
[string]$Tag
|
|
|
|
|
)
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
if (-not (Test-Command gpg)) {
|
|
|
|
|
Write-Warn "GPG not available - skipping tag signature verification"
|
|
|
|
|
Write-Warn "For enhanced security, install GPG and import the project signing key"
|
|
|
|
|
return $true # Continue without verification
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Push-Location $RepoPath
|
2026-02-01 00:20:37 +11:00
|
|
|
try {
|
2026-02-01 00:29:32 +11:00
|
|
|
# Attempt to verify the tag signature
|
2026-02-01 01:17:10 +00:00
|
|
|
# Use $ErrorActionPreference temporarily to prevent stderr from throwing
|
|
|
|
|
$oldErrorAction = $ErrorActionPreference
|
|
|
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
|
$result = git tag -v $Tag 2>&1 | Out-String
|
|
|
|
|
$exitCode = $LASTEXITCODE
|
|
|
|
|
$ErrorActionPreference = $oldErrorAction
|
|
|
|
|
|
|
|
|
|
if ($exitCode -eq 0) {
|
2026-02-01 00:29:32 +11:00
|
|
|
Write-Info "GPG signature verified for tag $Tag"
|
|
|
|
|
return $true
|
|
|
|
|
} else {
|
|
|
|
|
# Check if tag is unsigned vs signature invalid
|
2026-02-01 01:17:10 +00:00
|
|
|
if ($result -match "no signature found" -or $result -match "cannot verify a non-tag") {
|
2026-02-01 00:29:32 +11:00
|
|
|
Write-Warn "Tag $Tag is not signed - continuing without signature verification"
|
|
|
|
|
return $true
|
|
|
|
|
} else {
|
|
|
|
|
Write-Err "GPG signature verification FAILED for tag $Tag. Possible tampering detected."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
Pop-Location
|
2026-02-01 00:20:37 +11:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Build from source with security checks
|
2026-01-28 14:57:30 +00:00
|
|
|
function Build-FromSource {
|
2026-02-01 00:07:45 +11:00
|
|
|
if (-not (Test-Command git)) {
|
|
|
|
|
Write-Err "Git is required to build from source. Run '.\scripts\install-deps.ps1' first"
|
|
|
|
|
}
|
2026-01-28 14:57:30 +00:00
|
|
|
if (-not (Test-Command go)) {
|
|
|
|
|
Write-Err "Go is required to build from source. Run '.\scripts\install-deps.ps1' first"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Create secure temp directory with restrictive ACL
|
|
|
|
|
$tmpdir = Join-Path ([System.IO.Path]::GetTempPath()) "core-build-$([System.Guid]::NewGuid().ToString('N'))"
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = New-SecureDirectory -Path $tmpdir
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# ACL is REQUIRED for temp build directories (security critical)
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = Set-SecureDirectoryAcl -Path $tmpdir -Required
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:07:45 +11:00
|
|
|
try {
|
2026-02-02 02:36:18 +00:00
|
|
|
# Resolve latest version for reproducible builds
|
|
|
|
|
$version = Get-LatestVersion
|
|
|
|
|
if ($version) {
|
|
|
|
|
Write-Info "Resolved latest version: $version"
|
|
|
|
|
} else {
|
|
|
|
|
Write-Warn "Building from default branch (version unknown)"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 06:21:18 +00:00
|
|
|
Write-Info "Cloning $Repo..."
|
2026-02-01 00:07:45 +11:00
|
|
|
$cloneDir = Join-Path $tmpdir "Core"
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-02 02:36:18 +00:00
|
|
|
# Clone specific version if available, otherwise default branch
|
|
|
|
|
if ($version) {
|
|
|
|
|
git clone --depth 1 --branch $version "https://github.com/$Repo.git" $cloneDir
|
|
|
|
|
} else {
|
|
|
|
|
git clone --depth 1 "https://github.com/$Repo.git" $cloneDir
|
|
|
|
|
}
|
2026-02-01 00:07:45 +11:00
|
|
|
if ($LASTEXITCODE -ne 0) {
|
2026-02-01 06:21:18 +00:00
|
|
|
Write-Err "Failed to clone repository"
|
2026-02-01 01:17:10 +00:00
|
|
|
}
|
2026-02-01 00:29:32 +11:00
|
|
|
|
2026-02-01 00:07:45 +11:00
|
|
|
Write-Info "Building core CLI..."
|
|
|
|
|
Push-Location $cloneDir
|
2026-02-01 00:11:34 +11:00
|
|
|
try {
|
2026-02-01 01:17:10 +00:00
|
|
|
# Explicitly set GOOS/GOARCH to ensure Windows build
|
|
|
|
|
$env:GOOS = "windows"
|
|
|
|
|
$env:GOARCH = "amd64"
|
|
|
|
|
|
|
|
|
|
$oldErrorAction = $ErrorActionPreference
|
|
|
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
|
$buildOutput = go build -o core.exe . 2>&1 | Out-String
|
|
|
|
|
$buildExitCode = $LASTEXITCODE
|
|
|
|
|
$ErrorActionPreference = $oldErrorAction
|
|
|
|
|
|
|
|
|
|
if ($buildExitCode -ne 0) {
|
|
|
|
|
# Check for Windows-specific build issues
|
|
|
|
|
if ($buildOutput -match "Setpgid|Getpgid|syscall\.Kill|undefined.*syscall") {
|
|
|
|
|
Write-Warn "Build failed: core CLI uses Unix-specific syscalls not available on Windows."
|
|
|
|
|
Write-Warn ""
|
|
|
|
|
Write-Warn "Options:"
|
|
|
|
|
Write-Warn " 1. Wait for pre-built Windows binaries in GitHub releases"
|
|
|
|
|
Write-Warn " 2. Use WSL (Windows Subsystem for Linux) for development"
|
|
|
|
|
Write-Warn " 3. Work directly with composer in packages/ (core CLI is optional)"
|
|
|
|
|
Write-Warn ""
|
|
|
|
|
Write-Warn "For PHP development, you can use composer directly:"
|
|
|
|
|
Write-Warn " cd packages/core-php && composer test"
|
|
|
|
|
Write-Host ""
|
|
|
|
|
# Don't use Write-Err here - exit gracefully
|
|
|
|
|
exit 0
|
|
|
|
|
} else {
|
|
|
|
|
Write-Host $buildOutput
|
|
|
|
|
Write-Err "Go build failed"
|
|
|
|
|
}
|
2026-02-01 00:11:34 +11:00
|
|
|
}
|
|
|
|
|
} finally {
|
2026-02-01 00:07:45 +11:00
|
|
|
Pop-Location
|
|
|
|
|
}
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Create and verify install directory
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = New-SecureDirectory -Path $InstallDir
|
2026-02-01 00:20:37 +11:00
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Move built binary to install location
|
2026-02-01 00:07:45 +11:00
|
|
|
Move-Item (Join-Path $cloneDir "core.exe") (Join-Path $InstallDir "core.exe") -Force
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 00:07:45 +11:00
|
|
|
Write-Info "Built and installed to $InstallDir\core.exe"
|
|
|
|
|
} finally {
|
|
|
|
|
if (Test-Path $tmpdir) {
|
|
|
|
|
Remove-Item -Recurse -Force $tmpdir -ErrorAction SilentlyContinue
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 14:57:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Validate and add to PATH with precise matching
|
2026-01-28 14:57:30 +00:00
|
|
|
function Setup-Path {
|
|
|
|
|
$userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
2026-02-01 00:20:37 +11:00
|
|
|
|
|
|
|
|
# Validate InstallDir is a real directory under user profile
|
|
|
|
|
if (-not (Test-Path $InstallDir -PathType Container)) {
|
|
|
|
|
Write-Warn "Install directory does not exist, skipping PATH setup"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$resolvedInstallDir = [System.IO.Path]::GetFullPath($InstallDir)
|
|
|
|
|
$resolvedUserProfile = [System.IO.Path]::GetFullPath($env:USERPROFILE)
|
|
|
|
|
|
|
|
|
|
# Ensure we're only adding paths under user profile
|
|
|
|
|
if (-not $resolvedInstallDir.StartsWith($resolvedUserProfile, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
|
|
|
Write-Warn "Install directory is outside user profile, skipping PATH modification for security"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 00:29:32 +11:00
|
|
|
# Use precise PATH entry matching (not substring)
|
|
|
|
|
$pathEntries = $userPath -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
|
|
|
|
$normalizedInstallDir = $resolvedInstallDir.TrimEnd('\')
|
|
|
|
|
|
|
|
|
|
$alreadyInPath = $pathEntries | Where-Object {
|
|
|
|
|
$_.Equals($normalizedInstallDir, [System.StringComparison]::OrdinalIgnoreCase)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (-not $alreadyInPath) {
|
2026-01-28 14:57:30 +00:00
|
|
|
Write-Info "Adding $InstallDir to PATH..."
|
2026-02-01 00:54:45 +11:00
|
|
|
# Trim trailing semicolons to prevent duplicates
|
|
|
|
|
$cleanPath = $userPath.TrimEnd(';')
|
|
|
|
|
[Environment]::SetEnvironmentVariable("PATH", "$cleanPath;$InstallDir", "User")
|
2026-01-28 14:57:30 +00:00
|
|
|
$env:PATH = "$env:PATH;$InstallDir"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Verify installation
|
|
|
|
|
function Verify {
|
|
|
|
|
Setup-Path
|
|
|
|
|
|
|
|
|
|
if (Test-Command core) {
|
|
|
|
|
Write-Info "Verifying installation..."
|
|
|
|
|
& core --help | Select-Object -First 5
|
|
|
|
|
Write-Host ""
|
|
|
|
|
Write-Info "core CLI installed successfully!"
|
|
|
|
|
} elseif (Test-Path "$InstallDir\core.exe") {
|
|
|
|
|
Write-Info "core CLI installed to $InstallDir\core.exe"
|
|
|
|
|
Write-Info "Restart your terminal to use 'core' command"
|
|
|
|
|
} else {
|
|
|
|
|
Write-Err "Installation failed"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Main
|
|
|
|
|
function Main {
|
2026-02-01 06:21:18 +00:00
|
|
|
Write-Info "Installing Core CLI..."
|
2026-01-28 14:57:30 +00:00
|
|
|
|
2026-02-01 00:54:45 +11:00
|
|
|
# Check disk space before starting
|
2026-02-01 01:17:10 +00:00
|
|
|
$null = Test-DiskSpace -Path $InstallDir
|
2026-02-01 00:54:45 +11:00
|
|
|
|
2026-01-28 14:57:30 +00:00
|
|
|
# Try download first, fallback to build
|
|
|
|
|
if (-not (Download-Binary)) {
|
|
|
|
|
Build-FromSource
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Verify
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Main
|