| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100 |
- #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
- }
|