Files
bambuddy/install/windows-installer.ps1
T
Marko@VMHOMELAB 873c78a6ff Feature/windows installer (#1529)
Add feature: Added a powershells script to easily install bambuddy on windows
2026-06-02 15:17:25 +02:00

1101 lines
35 KiB
PowerShell

#requires -version 5.1
<#
.SYNOPSIS
Bambuddy Windows Installer
.DESCRIPTION
- Uses default install directory C:\Bambuddy
- Lets user choose custom install directory
- Checks and installs Git if missing
- Checks and installs Python 3 if missing
- Fixes permissions on install directory
- Clones Bambuddy repository
- Stores user data and application logs outside the Git checkout
- Creates Python venv
- Installs requirements
- Lets user choose port, default 8000
- Creates installer log
- Creates runtime log
- Optionally creates Windows Firewall rule
- Creates Start-Bambuddy.ps1
- Optionally registers Bambuddy as Windows Service using NSSM
- Optionally starts Bambuddy
.PARAMETER Yes
Runs unattended and accepts defaults for prompts.
.PARAMETER Silent
Runs unattended with reduced console output.
#>
[CmdletBinding()]
param (
[ValidateNotNullOrEmpty()]
[string]$InstallDir = "C:\Bambuddy",
[ValidateRange(1, 65535)]
[int]$Port = 8000,
[switch]$Yes,
[switch]$Silent,
[switch]$NoService,
[switch]$NoStart,
[switch]$LocalOnly
)
$ErrorActionPreference = "Stop"
$script:LogFile = $null
$script:Yes = [bool]$Yes
$script:Silent = [bool]$Silent
# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------
function Show-BambuddyBanner {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " ____ _ _ _ " -ForegroundColor Green
Write-Host " | _ \ | | | | | | " -ForegroundColor Green
Write-Host " | |_) | __ _ _ __ ___ | |__ _ _ __| | __| |_ _ " -ForegroundColor Green
Write-Host " | _ < / _` | '_ ` _ \| '_ \| | | |/ _` |/ _` | | | | " -ForegroundColor Green
Write-Host " | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | " -ForegroundColor Green
Write-Host " |____/ \__,_|_| |_| |_|_.__/ \__,_|\__,_|\__,_|\__, | " -ForegroundColor Green
Write-Host " __/ | " -ForegroundColor Green
Write-Host " |___/ " -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Bambuddy Setup - Install & Upgrade" -ForegroundColor White
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
}
function Write-Log {
param (
[Parameter(Mandatory = $true)]
[string]$Message,
[string]$Level = "INFO",
[ConsoleColor]$Color = [ConsoleColor]::White
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$timestamp] [$Level] $Message"
if (-not $script:Silent) {
Write-Host $Message -ForegroundColor $Color
}
if ($script:LogFile) {
try {
$line | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
}
catch {
Write-Host "Could not write to log file: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
}
function Start-InstallerLogging {
param (
[Parameter(Mandatory = $true)]
[string]$InstallDir
)
$script:LogFile = Join-Path $InstallDir "install.log"
if (-not (Test-Path $InstallDir)) {
New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null
}
try {
"" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
"============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
"Bambuddy Installer started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
"============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
Write-Log "Logging enabled: $script:LogFile" "INFO" Green
}
catch {
Write-Host "Could not initialize installer log: $($_.Exception.Message)" -ForegroundColor Yellow
$script:LogFile = $null
}
}
function Test-IsAdmin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}
function Restart-AsAdmin {
Write-Host "Script is not running as Administrator. Relaunching elevated..." -ForegroundColor Yellow
try {
$arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$PSCommandPath`"")
if ($InstallDir -ne "C:\Bambuddy") {
$arguments += @("-InstallDir", "`"$InstallDir`"")
}
if ($Port -ne 8000) {
$arguments += @("-Port", $Port)
}
if ($Yes) { $arguments += "-Yes" }
if ($Silent) { $arguments += "-Silent" }
if ($NoService) { $arguments += "-NoService" }
if ($NoStart) { $arguments += "-NoStart" }
if ($LocalOnly) { $arguments += "-LocalOnly" }
Start-Process powershell.exe `
-ArgumentList $arguments `
-Verb RunAs
exit
}
catch {
Write-Host "Elevation cancelled or failed. Please run PowerShell as Administrator." -ForegroundColor Red
if (-not $script:Silent) {
Read-Host "Press Enter to close"
}
exit 1
}
}
function Test-CommandExists {
param (
[Parameter(Mandatory = $true)]
[string]$Command
)
return [bool](Get-Command $Command -ErrorAction SilentlyContinue)
}
function Update-EnvironmentPath {
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$env:Path = "$machinePath;$userPath"
}
function Install-WithWinget {
param (
[Parameter(Mandatory = $true)]
[string]$PackageId,
[Parameter(Mandatory = $true)]
[string]$DisplayName
)
if (-not (Test-CommandExists "winget")) {
throw "winget is not available. Please install $DisplayName manually and run this script again."
}
Write-Log "Installing $DisplayName via winget..." "INFO" Cyan
& winget install `
--id $PackageId `
--exact `
--silent `
--accept-package-agreements `
--accept-source-agreements
if ($LASTEXITCODE -ne 0) {
throw "Failed to install $DisplayName via winget."
}
Update-EnvironmentPath
}
function Read-YesNo {
param (
[Parameter(Mandatory = $true)]
[string]$Question,
[bool]$DefaultYes = $true
)
# Keep installer prompts English-only until the installer has full locale support.
if ($script:Yes -or $script:Silent) {
return $DefaultYes
}
if ($DefaultYes) {
$suffix = "[Y/n]"
}
else {
$suffix = "[y/N]"
}
while ($true) {
$answer = Read-Host "$Question $suffix"
if ([string]::IsNullOrWhiteSpace($answer)) {
return $DefaultYes
}
switch ($answer.ToLower()) {
"y" { return $true }
"yes" { return $true }
"n" { return $false }
"no" { return $false }
default {
Write-Host "Please answer yes or no." -ForegroundColor Yellow
}
}
}
}
function Read-Port {
param (
[int]$DefaultPort = 8000
)
if ($script:Yes -or $script:Silent) {
return $DefaultPort
}
while ($true) {
$inputPort = Read-Host "Enter Bambuddy port or press Enter for default [$DefaultPort]"
if ([string]::IsNullOrWhiteSpace($inputPort)) {
return $DefaultPort
}
$parsedPort = 0
if ([int]::TryParse($inputPort, [ref]$parsedPort)) {
if ($parsedPort -ge 1 -and $parsedPort -le 65535) {
return $parsedPort
}
}
Write-Host "Invalid port. Please enter a number between 1 and 65535." -ForegroundColor Yellow
}
}
function Test-WriteAccess {
param (
[Parameter(Mandatory = $true)]
[string]$Path
)
try {
if (-not (Test-Path $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
$testFile = Join-Path $Path "write-test.tmp"
"test" | Set-Content -Path $testFile -Force
Remove-Item $testFile -Force
return $true
}
catch {
return $false
}
}
function Set-FolderPermissions {
param (
[Parameter(Mandatory = $true)]
[string]$Path
)
Write-Log "Fixing permissions for: $Path" "INFO" Cyan
if (-not (Test-Path $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
$currentUser = "$env:USERDOMAIN\$env:USERNAME"
try {
# Enable inheritance
& icacls $Path /inheritance:e | Out-Null
# Grant local Administrators full access by SID, language independent
& icacls $Path /grant "*S-1-5-32-544:(OI)(CI)F" /T /C | Out-Null
# Grant current user full access
& icacls $Path /grant "$($currentUser):(OI)(CI)F" /T /C | Out-Null
}
catch {
Write-Log "Permission adjustment failed or partially failed. Continuing with write test..." "WARN" Yellow
}
if (-not (Test-WriteAccess -Path $Path)) {
throw "No write permission to '$Path'. Try another path, for example C:\Temp\Bambuddy, or check Windows Defender Controlled Folder Access."
}
Write-Log "Write access confirmed." "INFO" Green
}
function Get-PythonCommand {
if (Test-CommandExists "python") {
if (Test-PythonVersion -PythonCommand "python") {
return "python"
}
}
if (Test-CommandExists "py") {
if (Test-PythonVersion -PythonCommand "py") {
return "py"
}
}
return $null
}
function Test-PythonVersion {
param (
[Parameter(Mandatory = $true)]
[string]$PythonCommand
)
try {
if ($PythonCommand -eq "py") {
$versionOutput = & py -3 --version 2>&1
}
else {
$versionOutput = & python --version 2>&1
}
if ($versionOutput -match "Python\s+(\d+)\.(\d+)\.(\d+)") {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 10)) {
return $true
}
Write-Log "Found $versionOutput, but Bambuddy requires Python 3.10 or newer." "WARN" Yellow
}
}
catch {}
return $false
}
function Test-PortAvailable {
param (
[Parameter(Mandatory = $true)]
[int]$Port
)
try {
$listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
return -not $listener
}
catch {
Write-Log "Could not check whether TCP port $Port is already in use. Continuing." "WARN" Yellow
return $true
}
}
function Move-LegacyRuntimeData {
param (
[Parameter(Mandatory = $true)]
[string]$BambuddyDir,
[Parameter(Mandatory = $true)]
[string]$DataDir,
[Parameter(Mandatory = $true)]
[string]$LogDir
)
$legacyMappings = @(
@{ Source = (Join-Path $BambuddyDir "bambuddy.db"); Destination = (Join-Path $DataDir "bambuddy.db") },
@{ Source = (Join-Path $BambuddyDir "archive"); Destination = (Join-Path $DataDir "archive") },
# external_links.py resolves user-uploaded icons to base_dir/icons; after
# DATA_DIR migration that becomes $DataDir/icons, so move any legacy ones.
@{ Source = (Join-Path $BambuddyDir "icons"); Destination = (Join-Path $DataDir "icons") }
)
foreach ($mapping in $legacyMappings) {
if ((Test-Path $mapping.Source) -and (-not (Test-Path $mapping.Destination))) {
Write-Log "Moving legacy runtime data from '$($mapping.Source)' to '$($mapping.Destination)'." "INFO" Cyan
Move-Item -Path $mapping.Source -Destination $mapping.Destination -Force
}
elseif ((Test-Path $mapping.Source) -and (Test-Path $mapping.Destination)) {
Write-Log "Legacy runtime data remains at '$($mapping.Source)' because '$($mapping.Destination)' already exists." "WARN" Yellow
}
}
$legacyDataRoot = Join-Path $BambuddyDir "data"
if (Test-Path $legacyDataRoot) {
$legacyDataItems = Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue
foreach ($legacyDataItem in $legacyDataItems) {
$destination = Join-Path $DataDir $legacyDataItem.Name
if (-not (Test-Path $destination)) {
Write-Log "Moving legacy runtime data from '$($legacyDataItem.FullName)' to '$destination'." "INFO" Cyan
Move-Item -Path $legacyDataItem.FullName -Destination $destination -Force
}
else {
Write-Log "Legacy runtime data remains at '$($legacyDataItem.FullName)' because '$destination' already exists." "WARN" Yellow
}
}
if (-not (Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue)) {
Remove-Item $legacyDataRoot -Force
}
}
$legacyLogDir = Join-Path $BambuddyDir "logs"
if (Test-Path $legacyLogDir) {
$legacyLogs = Get-ChildItem -Path $legacyLogDir -Force -ErrorAction SilentlyContinue
foreach ($legacyLog in $legacyLogs) {
$destination = Join-Path $LogDir $legacyLog.Name
if (-not (Test-Path $destination)) {
Write-Log "Moving legacy log '$($legacyLog.FullName)' to '$destination'." "INFO" Cyan
Move-Item -Path $legacyLog.FullName -Destination $destination -Force
}
}
}
}
function Invoke-Python {
param (
[Parameter(Mandatory = $true)]
[string]$PythonCommand,
[Parameter(Mandatory = $true)]
[string[]]$Arguments
)
if ($PythonCommand -eq "py") {
& py -3 @Arguments
}
else {
& python @Arguments
}
return $LASTEXITCODE
}
function Install-NSSM {
param (
[Parameter(Mandatory = $true)]
[string]$InstallDir
)
$nssmDir = Join-Path $InstallDir "nssm"
$nssmExe = Join-Path $nssmDir "nssm.exe"
if (Test-Path $nssmExe) {
Write-Log "NSSM already exists: $nssmExe" "INFO" Green
return $nssmExe
}
Write-Log "Installing NSSM..." "INFO" Cyan
$nssmZip = Join-Path $InstallDir "nssm.zip"
$nssmExtract = Join-Path $InstallDir "nssm_extract"
if (Test-Path $nssmExtract) {
Remove-Item $nssmExtract -Recurse -Force
}
New-Item -Path $nssmDir -ItemType Directory -Force | Out-Null
$nssmUrl = "https://nssm.cc/release/nssm-2.24.zip"
$expectedNssmSha256 = "727D1E42275C605E0F04ABA98095C38A8E1E46DEF453CDFFCE42869428AA6743"
Write-Log "Downloading NSSM from $nssmUrl" "INFO" Cyan
Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmZip -UseBasicParsing
$actualNssmSha256 = (Get-FileHash -Path $nssmZip -Algorithm SHA256).Hash
if ($actualNssmSha256 -ne $expectedNssmSha256) {
Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue
throw "NSSM download checksum mismatch. Expected $expectedNssmSha256 but got $actualNssmSha256."
}
Write-Log "NSSM checksum verified." "INFO" Green
Expand-Archive -Path $nssmZip -DestinationPath $nssmExtract -Force
$possibleNssmExe = Get-ChildItem -Path $nssmExtract -Recurse -Filter "nssm.exe" |
Where-Object { $_.FullName -match "\\win64\\" } |
Select-Object -First 1
if (-not $possibleNssmExe) {
throw "Could not find NSSM win64 executable after extraction."
}
Copy-Item -Path $possibleNssmExe.FullName -Destination $nssmExe -Force
Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue
Remove-Item $nssmExtract -Recurse -Force -ErrorAction SilentlyContinue
if (-not (Test-Path $nssmExe)) {
throw "NSSM installation failed. nssm.exe was not found."
}
Write-Log "NSSM installed: $nssmExe" "INFO" Green
return $nssmExe
}
function Register-BambuddyService {
param (
[Parameter(Mandatory = $true)]
[string]$ServiceName,
[Parameter(Mandatory = $true)]
[string]$StartScriptPath,
[Parameter(Mandatory = $true)]
[string]$InstallDir,
[Parameter(Mandatory = $true)]
[string]$BambuddyDir,
[Parameter(Mandatory = $true)]
[string]$RuntimeLogPath,
[Parameter(Mandatory = $true)]
[string]$RuntimeErrorLogPath,
[Parameter(Mandatory = $true)]
[string]$DataDir,
[Parameter(Mandatory = $true)]
[string]$LogDir
)
Write-Log "Preparing Windows Service registration using NSSM..." "INFO" Cyan
$nssmExe = Install-NSSM -InstallDir $InstallDir
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existingService) {
Write-Log "Service '$ServiceName' already exists." "WARN" Yellow
$replaceService = Read-YesNo -Question "Do you want to replace the existing service '$ServiceName'?" -DefaultYes $true
if ($replaceService) {
Write-Log "Stopping existing service if running..." "INFO" Cyan
try {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
}
catch {}
Write-Log "Removing existing service..." "INFO" Cyan
& $nssmExe remove $ServiceName confirm | Out-Null
Start-Sleep -Seconds 2
}
else {
Write-Log "Keeping existing service. Service registration skipped." "WARN" Yellow
return
}
}
$powerShellExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$serviceArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$StartScriptPath`""
Write-Log "Creating NSSM service '$ServiceName'..." "INFO" Cyan
& $nssmExe install $ServiceName $powerShellExe $serviceArguments
if ($LASTEXITCODE -ne 0) {
throw "NSSM failed to create service '$ServiceName'."
}
$configuredArguments = & $nssmExe get $ServiceName AppParameters
Write-Log "NSSM AppParameters: $configuredArguments" "INFO" Cyan
if ($configuredArguments -ne $serviceArguments) {
Write-Log "NSSM AppParameters differ from expected value. Verify paths with spaces before starting the service." "WARN" Yellow
}
& $nssmExe set $ServiceName DisplayName "Bambuddy"
& $nssmExe set $ServiceName Description "Bambuddy backend service"
& $nssmExe set $ServiceName AppDirectory $BambuddyDir
& $nssmExe set $ServiceName Start SERVICE_AUTO_START
& $nssmExe set $ServiceName AppEnvironmentExtra "+DATA_DIR=$DataDir" "+LOG_DIR=$LogDir"
# Logging
& $nssmExe set $ServiceName AppStdout $RuntimeLogPath
& $nssmExe set $ServiceName AppStderr $RuntimeErrorLogPath
& $nssmExe set $ServiceName AppRotateFiles 1
& $nssmExe set $ServiceName AppRotateOnline 1
& $nssmExe set $ServiceName AppRotateSeconds 86400
& $nssmExe set $ServiceName AppRotateBytes 10485760
# Restart behavior
& $nssmExe set $ServiceName AppExit Default Restart
& $nssmExe set $ServiceName AppExit 0 Exit
& $nssmExe set $ServiceName AppRestartDelay 5000
Write-Log "Service '$ServiceName' created successfully with NSSM." "INFO" Green
$startServiceNow = Read-YesNo -Question "Start Windows Service '$ServiceName' now?" -DefaultYes $true
if ($startServiceNow) {
Write-Log "Starting service '$ServiceName'..." "INFO" Cyan
Start-Service -Name $ServiceName
Start-Sleep -Seconds 5
$service = Get-Service -Name $ServiceName
Write-Log "Service state: $($service.Status)" "INFO" Green
if ($service.Status -ne "Running") {
Write-Log "Service did not stay running. Check runtime log: $RuntimeLogPath" "WARN" Yellow
}
}
}
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
try {
if (-not (Test-IsAdmin)) {
Restart-AsAdmin
}
if (-not $script:Silent) {
Show-BambuddyBanner
}
# ------------------------------------------------------------
# Install directory
# ------------------------------------------------------------
$defaultInstallDir = $InstallDir
if ($PSBoundParameters.ContainsKey("InstallDir")) {
$installDir = $InstallDir.Trim('"')
}
else {
$useDefaultDir = Read-YesNo -Question "Use default install directory '$defaultInstallDir'?" -DefaultYes $true
if ($useDefaultDir) {
$installDir = $defaultInstallDir
}
else {
while ($true) {
$customDir = Read-Host "Enter custom install directory"
if (-not [string]::IsNullOrWhiteSpace($customDir)) {
$installDir = $customDir.Trim('"')
break
}
Write-Host "Install directory cannot be empty." -ForegroundColor Yellow
}
}
}
if (-not (Test-Path $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
Start-InstallerLogging -InstallDir $installDir
Write-Log "Install directory: $installDir" "INFO" Cyan
Set-FolderPermissions -Path $installDir
# ------------------------------------------------------------
# Port selection
# ------------------------------------------------------------
$port = Read-Port -DefaultPort $Port
if (-not (Test-PortAvailable -Port $port)) {
throw "TCP port $port is already in use. Choose another port with -Port or stop the conflicting service."
}
Write-Log "Selected port: $port" "INFO" Cyan
$exposeOnLan = -not $LocalOnly
if (-not $LocalOnly) {
$lanQuestion = "Expose Bambuddy on the LAN? Choose No to bind only to this computer. Exposing Bambuddy on the LAN binds to all network interfaces. Bambuddy is unauthenticated by default. After installation, open Settings -> Security and enable auth before relying on LAN access."
$exposeOnLan = Read-YesNo -Question $lanQuestion -DefaultYes $true
}
if ($exposeOnLan) {
$bindAddress = "0.0.0.0"
}
else {
$bindAddress = "127.0.0.1"
}
Write-Log "Bind address: $bindAddress" "INFO" Cyan
# ------------------------------------------------------------
# Git check / install
# ------------------------------------------------------------
Write-Log "Checking Git..." "INFO" Cyan
if (-not (Test-CommandExists "git")) {
Write-Log "Git is not installed." "WARN" Yellow
Install-WithWinget -PackageId "Git.Git" -DisplayName "Git"
Update-EnvironmentPath
}
if (-not (Test-CommandExists "git")) {
throw "Git was installed, but is still not available in PATH. Restart PowerShell and run this script again."
}
$gitVersion = & git --version
Write-Log "Git found: $gitVersion" "INFO" Green
# ------------------------------------------------------------
# Python check / install
# ------------------------------------------------------------
Write-Log "Checking Python..." "INFO" Cyan
$pythonCommand = Get-PythonCommand
if (-not $pythonCommand) {
Write-Log "Python 3 is not installed." "WARN" Yellow
Install-WithWinget -PackageId "Python.Python.3.12" -DisplayName "Python 3"
Update-EnvironmentPath
$pythonCommand = Get-PythonCommand
}
if (-not $pythonCommand) {
throw "Python 3.10 or newer was not found in PATH. Restart PowerShell after installation or install Python 3.10+ manually."
}
Write-Log "Python command: $pythonCommand" "INFO" Green
# ------------------------------------------------------------
# Clone or update Bambuddy repository
# ------------------------------------------------------------
Write-Log "Preparing Bambuddy repository..." "INFO" Cyan
$bambuddyRepoUrl = "https://github.com/maziggy/bambuddy.git"
$bambuddyFolderName = "bambuddy"
$bambuddyDir = Join-Path $installDir $bambuddyFolderName
$dataDir = Join-Path $installDir "data"
$appLogDir = Join-Path $installDir "logs"
Write-Log "Repository target: $bambuddyDir" "INFO" Cyan
New-Item -Path $dataDir -ItemType Directory -Force | Out-Null
New-Item -Path $appLogDir -ItemType Directory -Force | Out-Null
if (Test-Path $bambuddyDir) {
$gitDir = Join-Path $bambuddyDir ".git"
if (Test-Path $gitDir) {
Write-Log "Existing Bambuddy Git repository found." "WARN" Yellow
$updateExisting = Read-YesNo -Question "Do you want to update the existing repository with git pull?" -DefaultYes $true
if ($updateExisting) {
Push-Location $bambuddyDir
Write-Log "Running git pull..." "INFO" Cyan
& git pull
$gitPullExitCode = $LASTEXITCODE
Pop-Location
if ($gitPullExitCode -ne 0) {
throw "git pull failed."
}
}
}
else {
Write-Log "Target directory exists but is not a valid Git repository: $bambuddyDir" "WARN" Yellow
$removeBroken = Read-YesNo -Question "Remove this directory and clone again? This deletes '$bambuddyDir'." -DefaultYes $false
if ($removeBroken) {
Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir
Write-Log "Removing existing target directory..." "INFO" Cyan
Remove-Item $bambuddyDir -Recurse -Force
}
else {
throw "Cannot continue because '$bambuddyDir' already exists and is not a Git repository."
}
}
}
if (-not (Test-Path $bambuddyDir)) {
Write-Log "Testing folder creation before git clone..." "INFO" Cyan
$testCloneDir = Join-Path $installDir "git-write-test"
if (Test-Path $testCloneDir) {
Remove-Item $testCloneDir -Recurse -Force
}
New-Item -Path $testCloneDir -ItemType Directory -Force | Out-Null
"test" | Set-Content -Path (Join-Path $testCloneDir "test.txt") -Force
Remove-Item $testCloneDir -Recurse -Force
Write-Log "Folder creation test successful." "INFO" Green
Write-Log "Cloning Bambuddy repository..." "INFO" Cyan
Push-Location $installDir
& git clone --depth=1 --progress $bambuddyRepoUrl $bambuddyFolderName
$gitCloneExitCode = $LASTEXITCODE
Pop-Location
if ($gitCloneExitCode -ne 0) {
throw "Failed to clone Bambuddy repository to '$bambuddyDir'."
}
}
if (-not (Test-Path $bambuddyDir)) {
throw "Bambuddy directory was not created: $bambuddyDir"
}
Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir
# ------------------------------------------------------------
# Python virtual environment
# ------------------------------------------------------------
Write-Log "Setting up Python virtual environment..." "INFO" Cyan
Push-Location $bambuddyDir
$venvDir = Join-Path $bambuddyDir "venv"
$venvPython = Join-Path $venvDir "Scripts\python.exe"
$venvPip = Join-Path $venvDir "Scripts\pip.exe"
if (-not (Test-Path $venvPython)) {
Write-Log "Creating virtual environment..." "INFO" Cyan
$venvExitCode = Invoke-Python -PythonCommand $pythonCommand -Arguments @("-m", "venv", "venv")
if ($venvExitCode -ne 0) {
Pop-Location
throw "Failed to create Python virtual environment."
}
}
else {
Write-Log "Virtual environment already exists." "INFO" Green
}
if (-not (Test-Path $venvPython)) {
Pop-Location
throw "Virtual environment Python executable was not found: $venvPython"
}
# ------------------------------------------------------------
# Install requirements
# ------------------------------------------------------------
Write-Log "Installing Python dependencies..." "INFO" Cyan
$requirementsFile = Join-Path $bambuddyDir "requirements.txt"
if (-not (Test-Path $requirementsFile)) {
Pop-Location
throw "requirements.txt was not found in $bambuddyDir"
}
Write-Log "Upgrading pip..." "INFO" Cyan
& $venvPython -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) {
Pop-Location
throw "Failed to upgrade pip."
}
Write-Log "Installing requirements.txt..." "INFO" Cyan
& $venvPip install -r $requirementsFile
if ($LASTEXITCODE -ne 0) {
Pop-Location
throw "Failed to install Python requirements."
}
Pop-Location
# ------------------------------------------------------------
# Firewall rule
# ------------------------------------------------------------
$createFirewallRule = Read-YesNo -Question "Create Windows Firewall rule for TCP port $port?" -DefaultYes $true
if ($createFirewallRule) {
$ruleName = "Bambuddy TCP $port"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existingRule) {
Write-Log "Creating firewall rule: $ruleName" "INFO" Cyan
# Restrict to trusted profiles. Bambuddy ships with auth disabled by
# default; allowing Public would expose an unauthenticated UI on
# cafe / hotel / airport networks the moment Windows classifies them.
New-NetFirewallRule `
-DisplayName $ruleName `
-Direction Inbound `
-Protocol TCP `
-LocalPort $port `
-Profile Domain,Private `
-Action Allow | Out-Null
Write-Log "Firewall rule created." "INFO" Green
}
else {
Write-Log "Firewall rule already exists: $ruleName" "WARN" Yellow
}
}
# ------------------------------------------------------------
# Create start script
# ------------------------------------------------------------
Write-Log "Creating start script..." "INFO" Cyan
$startScriptPath = Join-Path $installDir "Start-Bambuddy.ps1"
$runtimeLogPath = Join-Path $installDir "bambuddy-runtime.log"
$runtimeErrorLogPath = Join-Path $installDir "bambuddy-runtime-error.log"
$startScriptLines = @(
'$ErrorActionPreference = "Stop"',
'',
"`$BambuddyDir = `"$bambuddyDir`"",
"`$VenvPython = `"$venvPython`"",
"`$Port = $port",
"`$BindAddress = `"$bindAddress`"",
"`$env:DATA_DIR = `"$dataDir`"",
"`$env:LOG_DIR = `"$appLogDir`"",
'',
'Set-Location "$BambuddyDir"',
'',
'Write-Output "Starting Bambuddy on port $Port"',
'Write-Output "Bind address: $BindAddress"',
'Write-Output "Working directory: $BambuddyDir"',
'Write-Output "Python executable: $VenvPython"',
'Write-Output "Data directory: $env:DATA_DIR"',
'Write-Output "Log directory: $env:LOG_DIR"',
'',
'& "$VenvPython" -m uvicorn backend.app.main:app --host $BindAddress --port $Port'
)
$startScriptContent = $startScriptLines -join [Environment]::NewLine
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($startScriptPath, $startScriptContent, $utf8NoBom)
Write-Log "Start script created: $startScriptPath" "INFO" Green
Write-Log "Runtime log path: $runtimeLogPath" "INFO" Green
Write-Log "Runtime error log path: $runtimeErrorLogPath" "INFO" Green
# ------------------------------------------------------------
# Optional Windows Service registration
# ------------------------------------------------------------
$registerService = (-not $NoService) -and (Read-YesNo -Question "Register Bambuddy as a Windows Service?" -DefaultYes $true)
if ($registerService) {
Register-BambuddyService `
-ServiceName "Bambuddy" `
-StartScriptPath $startScriptPath `
-InstallDir $installDir `
-BambuddyDir $bambuddyDir `
-RuntimeLogPath $runtimeLogPath `
-RuntimeErrorLogPath $runtimeErrorLogPath `
-DataDir $dataDir `
-LogDir $appLogDir
}
# ------------------------------------------------------------
# Summary
# ------------------------------------------------------------
Write-Host ""
Write-Host "=== Installation completed ===" -ForegroundColor Green
Write-Host "Install directory: $installDir"
Write-Host "Repository path: $bambuddyDir"
Write-Host "Data directory: $dataDir"
Write-Host "App log directory: $appLogDir"
Write-Host "Port: $port"
Write-Host "Bind address: $bindAddress"
Write-Host "Installer log: $script:LogFile"
Write-Host "Service stdout: $runtimeLogPath"
Write-Host "Service stderr: $runtimeErrorLogPath"
Write-Host "Start script: $startScriptPath"
Write-Host ""
Write-Host "Manual start:"
Write-Host "powershell.exe -ExecutionPolicy Bypass -File `"$startScriptPath`""
Write-Host ""
Write-Host "Service commands:"
Write-Host "Start-Service Bambuddy"
Write-Host "Stop-Service Bambuddy"
Write-Host "Restart-Service Bambuddy"
Write-Host "Get-Service Bambuddy"
Write-Host ""
# ------------------------------------------------------------
# Start manually if service was not registered
# ------------------------------------------------------------
if ((-not $registerService) -and (-not $NoStart) -and (-not $script:Yes) -and (-not $script:Silent)) {
$startNow = Read-YesNo -Question "Start Bambuddy now?" -DefaultYes $true
if ($startNow) {
Write-Log "Starting Bambuddy manually..." "INFO" Green
Write-Host "Local URL: http://localhost:$port"
if ($bindAddress -eq "0.0.0.0") {
Write-Host "Network URL: http://<this-computer-ip>:$port"
}
Write-Host "Press CTRL+C to stop Bambuddy."
Write-Host ""
Set-Location $bambuddyDir
$env:DATA_DIR = $dataDir
$env:LOG_DIR = $appLogDir
& $venvPython -m uvicorn backend.app.main:app --host $bindAddress --port $port
}
}
# When relaunched elevated via Restart-AsAdmin, the new PowerShell window
# closes the moment the script returns. Pause so the install summary is
# readable. Matches the catch-block gate below.
if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) {
Write-Host ""
Read-Host "Press Enter to close"
}
}
catch {
Write-Host ""
Write-Host "ERROR:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
if ($script:LogFile) {
Add-Content -Path $script:LogFile -Value "[ERROR] $($_.Exception.Message)`n$($_.ScriptStackTrace)" -Encoding UTF8
Write-Host ""
Write-Host "Installer log: $script:LogFile" -ForegroundColor Yellow
}
if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) {
Write-Host ""
Read-Host "Press Enter to close"
}
exit 1
}