windows-installer.ps1 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100
  1. #requires -version 5.1
  2. <#
  3. .SYNOPSIS
  4. Bambuddy Windows Installer
  5. .DESCRIPTION
  6. - Uses default install directory C:\Bambuddy
  7. - Lets user choose custom install directory
  8. - Checks and installs Git if missing
  9. - Checks and installs Python 3 if missing
  10. - Fixes permissions on install directory
  11. - Clones Bambuddy repository
  12. - Stores user data and application logs outside the Git checkout
  13. - Creates Python venv
  14. - Installs requirements
  15. - Lets user choose port, default 8000
  16. - Creates installer log
  17. - Creates runtime log
  18. - Optionally creates Windows Firewall rule
  19. - Creates Start-Bambuddy.ps1
  20. - Optionally registers Bambuddy as Windows Service using NSSM
  21. - Optionally starts Bambuddy
  22. .PARAMETER Yes
  23. Runs unattended and accepts defaults for prompts.
  24. .PARAMETER Silent
  25. Runs unattended with reduced console output.
  26. #>
  27. [CmdletBinding()]
  28. param (
  29. [ValidateNotNullOrEmpty()]
  30. [string]$InstallDir = "C:\Bambuddy",
  31. [ValidateRange(1, 65535)]
  32. [int]$Port = 8000,
  33. [switch]$Yes,
  34. [switch]$Silent,
  35. [switch]$NoService,
  36. [switch]$NoStart,
  37. [switch]$LocalOnly
  38. )
  39. $ErrorActionPreference = "Stop"
  40. $script:LogFile = $null
  41. $script:Yes = [bool]$Yes
  42. $script:Silent = [bool]$Silent
  43. # ------------------------------------------------------------
  44. # Helper functions
  45. # ------------------------------------------------------------
  46. function Show-BambuddyBanner {
  47. Write-Host ""
  48. Write-Host "============================================================" -ForegroundColor Green
  49. Write-Host " ____ _ _ _ " -ForegroundColor Green
  50. Write-Host " | _ \ | | | | | | " -ForegroundColor Green
  51. Write-Host " | |_) | __ _ _ __ ___ | |__ _ _ __| | __| |_ _ " -ForegroundColor Green
  52. Write-Host " | _ < / _` | '_ ` _ \| '_ \| | | |/ _` |/ _` | | | | " -ForegroundColor Green
  53. Write-Host " | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | " -ForegroundColor Green
  54. Write-Host " |____/ \__,_|_| |_| |_|_.__/ \__,_|\__,_|\__,_|\__, | " -ForegroundColor Green
  55. Write-Host " __/ | " -ForegroundColor Green
  56. Write-Host " |___/ " -ForegroundColor Green
  57. Write-Host "============================================================" -ForegroundColor Green
  58. Write-Host " Bambuddy Setup - Install & Upgrade" -ForegroundColor White
  59. Write-Host "============================================================" -ForegroundColor Green
  60. Write-Host ""
  61. }
  62. function Write-Log {
  63. param (
  64. [Parameter(Mandatory = $true)]
  65. [string]$Message,
  66. [string]$Level = "INFO",
  67. [ConsoleColor]$Color = [ConsoleColor]::White
  68. )
  69. $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
  70. $line = "[$timestamp] [$Level] $Message"
  71. if (-not $script:Silent) {
  72. Write-Host $Message -ForegroundColor $Color
  73. }
  74. if ($script:LogFile) {
  75. try {
  76. $line | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
  77. }
  78. catch {
  79. Write-Host "Could not write to log file: $($_.Exception.Message)" -ForegroundColor Yellow
  80. }
  81. }
  82. }
  83. function Start-InstallerLogging {
  84. param (
  85. [Parameter(Mandatory = $true)]
  86. [string]$InstallDir
  87. )
  88. $script:LogFile = Join-Path $InstallDir "install.log"
  89. if (-not (Test-Path $InstallDir)) {
  90. New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null
  91. }
  92. try {
  93. "" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
  94. "============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
  95. "Bambuddy Installer started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
  96. "============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8
  97. Write-Log "Logging enabled: $script:LogFile" "INFO" Green
  98. }
  99. catch {
  100. Write-Host "Could not initialize installer log: $($_.Exception.Message)" -ForegroundColor Yellow
  101. $script:LogFile = $null
  102. }
  103. }
  104. function Test-IsAdmin {
  105. $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
  106. $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  107. return $principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
  108. }
  109. function Restart-AsAdmin {
  110. Write-Host "Script is not running as Administrator. Relaunching elevated..." -ForegroundColor Yellow
  111. try {
  112. $arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$PSCommandPath`"")
  113. if ($InstallDir -ne "C:\Bambuddy") {
  114. $arguments += @("-InstallDir", "`"$InstallDir`"")
  115. }
  116. if ($Port -ne 8000) {
  117. $arguments += @("-Port", $Port)
  118. }
  119. if ($Yes) { $arguments += "-Yes" }
  120. if ($Silent) { $arguments += "-Silent" }
  121. if ($NoService) { $arguments += "-NoService" }
  122. if ($NoStart) { $arguments += "-NoStart" }
  123. if ($LocalOnly) { $arguments += "-LocalOnly" }
  124. Start-Process powershell.exe `
  125. -ArgumentList $arguments `
  126. -Verb RunAs
  127. exit
  128. }
  129. catch {
  130. Write-Host "Elevation cancelled or failed. Please run PowerShell as Administrator." -ForegroundColor Red
  131. if (-not $script:Silent) {
  132. Read-Host "Press Enter to close"
  133. }
  134. exit 1
  135. }
  136. }
  137. function Test-CommandExists {
  138. param (
  139. [Parameter(Mandatory = $true)]
  140. [string]$Command
  141. )
  142. return [bool](Get-Command $Command -ErrorAction SilentlyContinue)
  143. }
  144. function Update-EnvironmentPath {
  145. $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
  146. $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
  147. $env:Path = "$machinePath;$userPath"
  148. }
  149. function Install-WithWinget {
  150. param (
  151. [Parameter(Mandatory = $true)]
  152. [string]$PackageId,
  153. [Parameter(Mandatory = $true)]
  154. [string]$DisplayName
  155. )
  156. if (-not (Test-CommandExists "winget")) {
  157. throw "winget is not available. Please install $DisplayName manually and run this script again."
  158. }
  159. Write-Log "Installing $DisplayName via winget..." "INFO" Cyan
  160. & winget install `
  161. --id $PackageId `
  162. --exact `
  163. --silent `
  164. --accept-package-agreements `
  165. --accept-source-agreements
  166. if ($LASTEXITCODE -ne 0) {
  167. throw "Failed to install $DisplayName via winget."
  168. }
  169. Update-EnvironmentPath
  170. }
  171. function Read-YesNo {
  172. param (
  173. [Parameter(Mandatory = $true)]
  174. [string]$Question,
  175. [bool]$DefaultYes = $true
  176. )
  177. # Keep installer prompts English-only until the installer has full locale support.
  178. if ($script:Yes -or $script:Silent) {
  179. return $DefaultYes
  180. }
  181. if ($DefaultYes) {
  182. $suffix = "[Y/n]"
  183. }
  184. else {
  185. $suffix = "[y/N]"
  186. }
  187. while ($true) {
  188. $answer = Read-Host "$Question $suffix"
  189. if ([string]::IsNullOrWhiteSpace($answer)) {
  190. return $DefaultYes
  191. }
  192. switch ($answer.ToLower()) {
  193. "y" { return $true }
  194. "yes" { return $true }
  195. "n" { return $false }
  196. "no" { return $false }
  197. default {
  198. Write-Host "Please answer yes or no." -ForegroundColor Yellow
  199. }
  200. }
  201. }
  202. }
  203. function Read-Port {
  204. param (
  205. [int]$DefaultPort = 8000
  206. )
  207. if ($script:Yes -or $script:Silent) {
  208. return $DefaultPort
  209. }
  210. while ($true) {
  211. $inputPort = Read-Host "Enter Bambuddy port or press Enter for default [$DefaultPort]"
  212. if ([string]::IsNullOrWhiteSpace($inputPort)) {
  213. return $DefaultPort
  214. }
  215. $parsedPort = 0
  216. if ([int]::TryParse($inputPort, [ref]$parsedPort)) {
  217. if ($parsedPort -ge 1 -and $parsedPort -le 65535) {
  218. return $parsedPort
  219. }
  220. }
  221. Write-Host "Invalid port. Please enter a number between 1 and 65535." -ForegroundColor Yellow
  222. }
  223. }
  224. function Test-WriteAccess {
  225. param (
  226. [Parameter(Mandatory = $true)]
  227. [string]$Path
  228. )
  229. try {
  230. if (-not (Test-Path $Path)) {
  231. New-Item -Path $Path -ItemType Directory -Force | Out-Null
  232. }
  233. $testFile = Join-Path $Path "write-test.tmp"
  234. "test" | Set-Content -Path $testFile -Force
  235. Remove-Item $testFile -Force
  236. return $true
  237. }
  238. catch {
  239. return $false
  240. }
  241. }
  242. function Set-FolderPermissions {
  243. param (
  244. [Parameter(Mandatory = $true)]
  245. [string]$Path
  246. )
  247. Write-Log "Fixing permissions for: $Path" "INFO" Cyan
  248. if (-not (Test-Path $Path)) {
  249. New-Item -Path $Path -ItemType Directory -Force | Out-Null
  250. }
  251. $currentUser = "$env:USERDOMAIN\$env:USERNAME"
  252. try {
  253. # Enable inheritance
  254. & icacls $Path /inheritance:e | Out-Null
  255. # Grant local Administrators full access by SID, language independent
  256. & icacls $Path /grant "*S-1-5-32-544:(OI)(CI)F" /T /C | Out-Null
  257. # Grant current user full access
  258. & icacls $Path /grant "$($currentUser):(OI)(CI)F" /T /C | Out-Null
  259. }
  260. catch {
  261. Write-Log "Permission adjustment failed or partially failed. Continuing with write test..." "WARN" Yellow
  262. }
  263. if (-not (Test-WriteAccess -Path $Path)) {
  264. throw "No write permission to '$Path'. Try another path, for example C:\Temp\Bambuddy, or check Windows Defender Controlled Folder Access."
  265. }
  266. Write-Log "Write access confirmed." "INFO" Green
  267. }
  268. function Get-PythonCommand {
  269. if (Test-CommandExists "python") {
  270. if (Test-PythonVersion -PythonCommand "python") {
  271. return "python"
  272. }
  273. }
  274. if (Test-CommandExists "py") {
  275. if (Test-PythonVersion -PythonCommand "py") {
  276. return "py"
  277. }
  278. }
  279. return $null
  280. }
  281. function Test-PythonVersion {
  282. param (
  283. [Parameter(Mandatory = $true)]
  284. [string]$PythonCommand
  285. )
  286. try {
  287. if ($PythonCommand -eq "py") {
  288. $versionOutput = & py -3 --version 2>&1
  289. }
  290. else {
  291. $versionOutput = & python --version 2>&1
  292. }
  293. if ($versionOutput -match "Python\s+(\d+)\.(\d+)\.(\d+)") {
  294. $major = [int]$Matches[1]
  295. $minor = [int]$Matches[2]
  296. if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 10)) {
  297. return $true
  298. }
  299. Write-Log "Found $versionOutput, but Bambuddy requires Python 3.10 or newer." "WARN" Yellow
  300. }
  301. }
  302. catch {}
  303. return $false
  304. }
  305. function Test-PortAvailable {
  306. param (
  307. [Parameter(Mandatory = $true)]
  308. [int]$Port
  309. )
  310. try {
  311. $listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
  312. return -not $listener
  313. }
  314. catch {
  315. Write-Log "Could not check whether TCP port $Port is already in use. Continuing." "WARN" Yellow
  316. return $true
  317. }
  318. }
  319. function Move-LegacyRuntimeData {
  320. param (
  321. [Parameter(Mandatory = $true)]
  322. [string]$BambuddyDir,
  323. [Parameter(Mandatory = $true)]
  324. [string]$DataDir,
  325. [Parameter(Mandatory = $true)]
  326. [string]$LogDir
  327. )
  328. $legacyMappings = @(
  329. @{ Source = (Join-Path $BambuddyDir "bambuddy.db"); Destination = (Join-Path $DataDir "bambuddy.db") },
  330. @{ Source = (Join-Path $BambuddyDir "archive"); Destination = (Join-Path $DataDir "archive") },
  331. # external_links.py resolves user-uploaded icons to base_dir/icons; after
  332. # DATA_DIR migration that becomes $DataDir/icons, so move any legacy ones.
  333. @{ Source = (Join-Path $BambuddyDir "icons"); Destination = (Join-Path $DataDir "icons") }
  334. )
  335. foreach ($mapping in $legacyMappings) {
  336. if ((Test-Path $mapping.Source) -and (-not (Test-Path $mapping.Destination))) {
  337. Write-Log "Moving legacy runtime data from '$($mapping.Source)' to '$($mapping.Destination)'." "INFO" Cyan
  338. Move-Item -Path $mapping.Source -Destination $mapping.Destination -Force
  339. }
  340. elseif ((Test-Path $mapping.Source) -and (Test-Path $mapping.Destination)) {
  341. Write-Log "Legacy runtime data remains at '$($mapping.Source)' because '$($mapping.Destination)' already exists." "WARN" Yellow
  342. }
  343. }
  344. $legacyDataRoot = Join-Path $BambuddyDir "data"
  345. if (Test-Path $legacyDataRoot) {
  346. $legacyDataItems = Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue
  347. foreach ($legacyDataItem in $legacyDataItems) {
  348. $destination = Join-Path $DataDir $legacyDataItem.Name
  349. if (-not (Test-Path $destination)) {
  350. Write-Log "Moving legacy runtime data from '$($legacyDataItem.FullName)' to '$destination'." "INFO" Cyan
  351. Move-Item -Path $legacyDataItem.FullName -Destination $destination -Force
  352. }
  353. else {
  354. Write-Log "Legacy runtime data remains at '$($legacyDataItem.FullName)' because '$destination' already exists." "WARN" Yellow
  355. }
  356. }
  357. if (-not (Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue)) {
  358. Remove-Item $legacyDataRoot -Force
  359. }
  360. }
  361. $legacyLogDir = Join-Path $BambuddyDir "logs"
  362. if (Test-Path $legacyLogDir) {
  363. $legacyLogs = Get-ChildItem -Path $legacyLogDir -Force -ErrorAction SilentlyContinue
  364. foreach ($legacyLog in $legacyLogs) {
  365. $destination = Join-Path $LogDir $legacyLog.Name
  366. if (-not (Test-Path $destination)) {
  367. Write-Log "Moving legacy log '$($legacyLog.FullName)' to '$destination'." "INFO" Cyan
  368. Move-Item -Path $legacyLog.FullName -Destination $destination -Force
  369. }
  370. }
  371. }
  372. }
  373. function Invoke-Python {
  374. param (
  375. [Parameter(Mandatory = $true)]
  376. [string]$PythonCommand,
  377. [Parameter(Mandatory = $true)]
  378. [string[]]$Arguments
  379. )
  380. if ($PythonCommand -eq "py") {
  381. & py -3 @Arguments
  382. }
  383. else {
  384. & python @Arguments
  385. }
  386. return $LASTEXITCODE
  387. }
  388. function Install-NSSM {
  389. param (
  390. [Parameter(Mandatory = $true)]
  391. [string]$InstallDir
  392. )
  393. $nssmDir = Join-Path $InstallDir "nssm"
  394. $nssmExe = Join-Path $nssmDir "nssm.exe"
  395. if (Test-Path $nssmExe) {
  396. Write-Log "NSSM already exists: $nssmExe" "INFO" Green
  397. return $nssmExe
  398. }
  399. Write-Log "Installing NSSM..." "INFO" Cyan
  400. $nssmZip = Join-Path $InstallDir "nssm.zip"
  401. $nssmExtract = Join-Path $InstallDir "nssm_extract"
  402. if (Test-Path $nssmExtract) {
  403. Remove-Item $nssmExtract -Recurse -Force
  404. }
  405. New-Item -Path $nssmDir -ItemType Directory -Force | Out-Null
  406. $nssmUrl = "https://nssm.cc/release/nssm-2.24.zip"
  407. $expectedNssmSha256 = "727D1E42275C605E0F04ABA98095C38A8E1E46DEF453CDFFCE42869428AA6743"
  408. Write-Log "Downloading NSSM from $nssmUrl" "INFO" Cyan
  409. Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmZip -UseBasicParsing
  410. $actualNssmSha256 = (Get-FileHash -Path $nssmZip -Algorithm SHA256).Hash
  411. if ($actualNssmSha256 -ne $expectedNssmSha256) {
  412. Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue
  413. throw "NSSM download checksum mismatch. Expected $expectedNssmSha256 but got $actualNssmSha256."
  414. }
  415. Write-Log "NSSM checksum verified." "INFO" Green
  416. Expand-Archive -Path $nssmZip -DestinationPath $nssmExtract -Force
  417. $possibleNssmExe = Get-ChildItem -Path $nssmExtract -Recurse -Filter "nssm.exe" |
  418. Where-Object { $_.FullName -match "\\win64\\" } |
  419. Select-Object -First 1
  420. if (-not $possibleNssmExe) {
  421. throw "Could not find NSSM win64 executable after extraction."
  422. }
  423. Copy-Item -Path $possibleNssmExe.FullName -Destination $nssmExe -Force
  424. Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue
  425. Remove-Item $nssmExtract -Recurse -Force -ErrorAction SilentlyContinue
  426. if (-not (Test-Path $nssmExe)) {
  427. throw "NSSM installation failed. nssm.exe was not found."
  428. }
  429. Write-Log "NSSM installed: $nssmExe" "INFO" Green
  430. return $nssmExe
  431. }
  432. function Register-BambuddyService {
  433. param (
  434. [Parameter(Mandatory = $true)]
  435. [string]$ServiceName,
  436. [Parameter(Mandatory = $true)]
  437. [string]$StartScriptPath,
  438. [Parameter(Mandatory = $true)]
  439. [string]$InstallDir,
  440. [Parameter(Mandatory = $true)]
  441. [string]$BambuddyDir,
  442. [Parameter(Mandatory = $true)]
  443. [string]$RuntimeLogPath,
  444. [Parameter(Mandatory = $true)]
  445. [string]$RuntimeErrorLogPath,
  446. [Parameter(Mandatory = $true)]
  447. [string]$DataDir,
  448. [Parameter(Mandatory = $true)]
  449. [string]$LogDir
  450. )
  451. Write-Log "Preparing Windows Service registration using NSSM..." "INFO" Cyan
  452. $nssmExe = Install-NSSM -InstallDir $InstallDir
  453. $existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
  454. if ($existingService) {
  455. Write-Log "Service '$ServiceName' already exists." "WARN" Yellow
  456. $replaceService = Read-YesNo -Question "Do you want to replace the existing service '$ServiceName'?" -DefaultYes $true
  457. if ($replaceService) {
  458. Write-Log "Stopping existing service if running..." "INFO" Cyan
  459. try {
  460. Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
  461. Start-Sleep -Seconds 2
  462. }
  463. catch {}
  464. Write-Log "Removing existing service..." "INFO" Cyan
  465. & $nssmExe remove $ServiceName confirm | Out-Null
  466. Start-Sleep -Seconds 2
  467. }
  468. else {
  469. Write-Log "Keeping existing service. Service registration skipped." "WARN" Yellow
  470. return
  471. }
  472. }
  473. $powerShellExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
  474. $serviceArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$StartScriptPath`""
  475. Write-Log "Creating NSSM service '$ServiceName'..." "INFO" Cyan
  476. & $nssmExe install $ServiceName $powerShellExe $serviceArguments
  477. if ($LASTEXITCODE -ne 0) {
  478. throw "NSSM failed to create service '$ServiceName'."
  479. }
  480. $configuredArguments = & $nssmExe get $ServiceName AppParameters
  481. Write-Log "NSSM AppParameters: $configuredArguments" "INFO" Cyan
  482. if ($configuredArguments -ne $serviceArguments) {
  483. Write-Log "NSSM AppParameters differ from expected value. Verify paths with spaces before starting the service." "WARN" Yellow
  484. }
  485. & $nssmExe set $ServiceName DisplayName "Bambuddy"
  486. & $nssmExe set $ServiceName Description "Bambuddy backend service"
  487. & $nssmExe set $ServiceName AppDirectory $BambuddyDir
  488. & $nssmExe set $ServiceName Start SERVICE_AUTO_START
  489. & $nssmExe set $ServiceName AppEnvironmentExtra "+DATA_DIR=$DataDir" "+LOG_DIR=$LogDir"
  490. # Logging
  491. & $nssmExe set $ServiceName AppStdout $RuntimeLogPath
  492. & $nssmExe set $ServiceName AppStderr $RuntimeErrorLogPath
  493. & $nssmExe set $ServiceName AppRotateFiles 1
  494. & $nssmExe set $ServiceName AppRotateOnline 1
  495. & $nssmExe set $ServiceName AppRotateSeconds 86400
  496. & $nssmExe set $ServiceName AppRotateBytes 10485760
  497. # Restart behavior
  498. & $nssmExe set $ServiceName AppExit Default Restart
  499. & $nssmExe set $ServiceName AppExit 0 Exit
  500. & $nssmExe set $ServiceName AppRestartDelay 5000
  501. Write-Log "Service '$ServiceName' created successfully with NSSM." "INFO" Green
  502. $startServiceNow = Read-YesNo -Question "Start Windows Service '$ServiceName' now?" -DefaultYes $true
  503. if ($startServiceNow) {
  504. Write-Log "Starting service '$ServiceName'..." "INFO" Cyan
  505. Start-Service -Name $ServiceName
  506. Start-Sleep -Seconds 5
  507. $service = Get-Service -Name $ServiceName
  508. Write-Log "Service state: $($service.Status)" "INFO" Green
  509. if ($service.Status -ne "Running") {
  510. Write-Log "Service did not stay running. Check runtime log: $RuntimeLogPath" "WARN" Yellow
  511. }
  512. }
  513. }
  514. # ------------------------------------------------------------
  515. # Main
  516. # ------------------------------------------------------------
  517. try {
  518. if (-not (Test-IsAdmin)) {
  519. Restart-AsAdmin
  520. }
  521. if (-not $script:Silent) {
  522. Show-BambuddyBanner
  523. }
  524. # ------------------------------------------------------------
  525. # Install directory
  526. # ------------------------------------------------------------
  527. $defaultInstallDir = $InstallDir
  528. if ($PSBoundParameters.ContainsKey("InstallDir")) {
  529. $installDir = $InstallDir.Trim('"')
  530. }
  531. else {
  532. $useDefaultDir = Read-YesNo -Question "Use default install directory '$defaultInstallDir'?" -DefaultYes $true
  533. if ($useDefaultDir) {
  534. $installDir = $defaultInstallDir
  535. }
  536. else {
  537. while ($true) {
  538. $customDir = Read-Host "Enter custom install directory"
  539. if (-not [string]::IsNullOrWhiteSpace($customDir)) {
  540. $installDir = $customDir.Trim('"')
  541. break
  542. }
  543. Write-Host "Install directory cannot be empty." -ForegroundColor Yellow
  544. }
  545. }
  546. }
  547. if (-not (Test-Path $installDir)) {
  548. New-Item -Path $installDir -ItemType Directory -Force | Out-Null
  549. }
  550. Start-InstallerLogging -InstallDir $installDir
  551. Write-Log "Install directory: $installDir" "INFO" Cyan
  552. Set-FolderPermissions -Path $installDir
  553. # ------------------------------------------------------------
  554. # Port selection
  555. # ------------------------------------------------------------
  556. $port = Read-Port -DefaultPort $Port
  557. if (-not (Test-PortAvailable -Port $port)) {
  558. throw "TCP port $port is already in use. Choose another port with -Port or stop the conflicting service."
  559. }
  560. Write-Log "Selected port: $port" "INFO" Cyan
  561. $exposeOnLan = -not $LocalOnly
  562. if (-not $LocalOnly) {
  563. $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."
  564. $exposeOnLan = Read-YesNo -Question $lanQuestion -DefaultYes $true
  565. }
  566. if ($exposeOnLan) {
  567. $bindAddress = "0.0.0.0"
  568. }
  569. else {
  570. $bindAddress = "127.0.0.1"
  571. }
  572. Write-Log "Bind address: $bindAddress" "INFO" Cyan
  573. # ------------------------------------------------------------
  574. # Git check / install
  575. # ------------------------------------------------------------
  576. Write-Log "Checking Git..." "INFO" Cyan
  577. if (-not (Test-CommandExists "git")) {
  578. Write-Log "Git is not installed." "WARN" Yellow
  579. Install-WithWinget -PackageId "Git.Git" -DisplayName "Git"
  580. Update-EnvironmentPath
  581. }
  582. if (-not (Test-CommandExists "git")) {
  583. throw "Git was installed, but is still not available in PATH. Restart PowerShell and run this script again."
  584. }
  585. $gitVersion = & git --version
  586. Write-Log "Git found: $gitVersion" "INFO" Green
  587. # ------------------------------------------------------------
  588. # Python check / install
  589. # ------------------------------------------------------------
  590. Write-Log "Checking Python..." "INFO" Cyan
  591. $pythonCommand = Get-PythonCommand
  592. if (-not $pythonCommand) {
  593. Write-Log "Python 3 is not installed." "WARN" Yellow
  594. Install-WithWinget -PackageId "Python.Python.3.12" -DisplayName "Python 3"
  595. Update-EnvironmentPath
  596. $pythonCommand = Get-PythonCommand
  597. }
  598. if (-not $pythonCommand) {
  599. throw "Python 3.10 or newer was not found in PATH. Restart PowerShell after installation or install Python 3.10+ manually."
  600. }
  601. Write-Log "Python command: $pythonCommand" "INFO" Green
  602. # ------------------------------------------------------------
  603. # Clone or update Bambuddy repository
  604. # ------------------------------------------------------------
  605. Write-Log "Preparing Bambuddy repository..." "INFO" Cyan
  606. $bambuddyRepoUrl = "https://github.com/maziggy/bambuddy.git"
  607. $bambuddyFolderName = "bambuddy"
  608. $bambuddyDir = Join-Path $installDir $bambuddyFolderName
  609. $dataDir = Join-Path $installDir "data"
  610. $appLogDir = Join-Path $installDir "logs"
  611. Write-Log "Repository target: $bambuddyDir" "INFO" Cyan
  612. New-Item -Path $dataDir -ItemType Directory -Force | Out-Null
  613. New-Item -Path $appLogDir -ItemType Directory -Force | Out-Null
  614. if (Test-Path $bambuddyDir) {
  615. $gitDir = Join-Path $bambuddyDir ".git"
  616. if (Test-Path $gitDir) {
  617. Write-Log "Existing Bambuddy Git repository found." "WARN" Yellow
  618. $updateExisting = Read-YesNo -Question "Do you want to update the existing repository with git pull?" -DefaultYes $true
  619. if ($updateExisting) {
  620. Push-Location $bambuddyDir
  621. Write-Log "Running git pull..." "INFO" Cyan
  622. & git pull
  623. $gitPullExitCode = $LASTEXITCODE
  624. Pop-Location
  625. if ($gitPullExitCode -ne 0) {
  626. throw "git pull failed."
  627. }
  628. }
  629. }
  630. else {
  631. Write-Log "Target directory exists but is not a valid Git repository: $bambuddyDir" "WARN" Yellow
  632. $removeBroken = Read-YesNo -Question "Remove this directory and clone again? This deletes '$bambuddyDir'." -DefaultYes $false
  633. if ($removeBroken) {
  634. Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir
  635. Write-Log "Removing existing target directory..." "INFO" Cyan
  636. Remove-Item $bambuddyDir -Recurse -Force
  637. }
  638. else {
  639. throw "Cannot continue because '$bambuddyDir' already exists and is not a Git repository."
  640. }
  641. }
  642. }
  643. if (-not (Test-Path $bambuddyDir)) {
  644. Write-Log "Testing folder creation before git clone..." "INFO" Cyan
  645. $testCloneDir = Join-Path $installDir "git-write-test"
  646. if (Test-Path $testCloneDir) {
  647. Remove-Item $testCloneDir -Recurse -Force
  648. }
  649. New-Item -Path $testCloneDir -ItemType Directory -Force | Out-Null
  650. "test" | Set-Content -Path (Join-Path $testCloneDir "test.txt") -Force
  651. Remove-Item $testCloneDir -Recurse -Force
  652. Write-Log "Folder creation test successful." "INFO" Green
  653. Write-Log "Cloning Bambuddy repository..." "INFO" Cyan
  654. Push-Location $installDir
  655. & git clone --depth=1 --progress $bambuddyRepoUrl $bambuddyFolderName
  656. $gitCloneExitCode = $LASTEXITCODE
  657. Pop-Location
  658. if ($gitCloneExitCode -ne 0) {
  659. throw "Failed to clone Bambuddy repository to '$bambuddyDir'."
  660. }
  661. }
  662. if (-not (Test-Path $bambuddyDir)) {
  663. throw "Bambuddy directory was not created: $bambuddyDir"
  664. }
  665. Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir
  666. # ------------------------------------------------------------
  667. # Python virtual environment
  668. # ------------------------------------------------------------
  669. Write-Log "Setting up Python virtual environment..." "INFO" Cyan
  670. Push-Location $bambuddyDir
  671. $venvDir = Join-Path $bambuddyDir "venv"
  672. $venvPython = Join-Path $venvDir "Scripts\python.exe"
  673. $venvPip = Join-Path $venvDir "Scripts\pip.exe"
  674. if (-not (Test-Path $venvPython)) {
  675. Write-Log "Creating virtual environment..." "INFO" Cyan
  676. $venvExitCode = Invoke-Python -PythonCommand $pythonCommand -Arguments @("-m", "venv", "venv")
  677. if ($venvExitCode -ne 0) {
  678. Pop-Location
  679. throw "Failed to create Python virtual environment."
  680. }
  681. }
  682. else {
  683. Write-Log "Virtual environment already exists." "INFO" Green
  684. }
  685. if (-not (Test-Path $venvPython)) {
  686. Pop-Location
  687. throw "Virtual environment Python executable was not found: $venvPython"
  688. }
  689. # ------------------------------------------------------------
  690. # Install requirements
  691. # ------------------------------------------------------------
  692. Write-Log "Installing Python dependencies..." "INFO" Cyan
  693. $requirementsFile = Join-Path $bambuddyDir "requirements.txt"
  694. if (-not (Test-Path $requirementsFile)) {
  695. Pop-Location
  696. throw "requirements.txt was not found in $bambuddyDir"
  697. }
  698. Write-Log "Upgrading pip..." "INFO" Cyan
  699. & $venvPython -m pip install --upgrade pip
  700. if ($LASTEXITCODE -ne 0) {
  701. Pop-Location
  702. throw "Failed to upgrade pip."
  703. }
  704. Write-Log "Installing requirements.txt..." "INFO" Cyan
  705. & $venvPip install -r $requirementsFile
  706. if ($LASTEXITCODE -ne 0) {
  707. Pop-Location
  708. throw "Failed to install Python requirements."
  709. }
  710. Pop-Location
  711. # ------------------------------------------------------------
  712. # Firewall rule
  713. # ------------------------------------------------------------
  714. $createFirewallRule = Read-YesNo -Question "Create Windows Firewall rule for TCP port $port?" -DefaultYes $true
  715. if ($createFirewallRule) {
  716. $ruleName = "Bambuddy TCP $port"
  717. $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
  718. if (-not $existingRule) {
  719. Write-Log "Creating firewall rule: $ruleName" "INFO" Cyan
  720. # Restrict to trusted profiles. Bambuddy ships with auth disabled by
  721. # default; allowing Public would expose an unauthenticated UI on
  722. # cafe / hotel / airport networks the moment Windows classifies them.
  723. New-NetFirewallRule `
  724. -DisplayName $ruleName `
  725. -Direction Inbound `
  726. -Protocol TCP `
  727. -LocalPort $port `
  728. -Profile Domain,Private `
  729. -Action Allow | Out-Null
  730. Write-Log "Firewall rule created." "INFO" Green
  731. }
  732. else {
  733. Write-Log "Firewall rule already exists: $ruleName" "WARN" Yellow
  734. }
  735. }
  736. # ------------------------------------------------------------
  737. # Create start script
  738. # ------------------------------------------------------------
  739. Write-Log "Creating start script..." "INFO" Cyan
  740. $startScriptPath = Join-Path $installDir "Start-Bambuddy.ps1"
  741. $runtimeLogPath = Join-Path $installDir "bambuddy-runtime.log"
  742. $runtimeErrorLogPath = Join-Path $installDir "bambuddy-runtime-error.log"
  743. $startScriptLines = @(
  744. '$ErrorActionPreference = "Stop"',
  745. '',
  746. "`$BambuddyDir = `"$bambuddyDir`"",
  747. "`$VenvPython = `"$venvPython`"",
  748. "`$Port = $port",
  749. "`$BindAddress = `"$bindAddress`"",
  750. "`$env:DATA_DIR = `"$dataDir`"",
  751. "`$env:LOG_DIR = `"$appLogDir`"",
  752. '',
  753. 'Set-Location "$BambuddyDir"',
  754. '',
  755. 'Write-Output "Starting Bambuddy on port $Port"',
  756. 'Write-Output "Bind address: $BindAddress"',
  757. 'Write-Output "Working directory: $BambuddyDir"',
  758. 'Write-Output "Python executable: $VenvPython"',
  759. 'Write-Output "Data directory: $env:DATA_DIR"',
  760. 'Write-Output "Log directory: $env:LOG_DIR"',
  761. '',
  762. '& "$VenvPython" -m uvicorn backend.app.main:app --host $BindAddress --port $Port'
  763. )
  764. $startScriptContent = $startScriptLines -join [Environment]::NewLine
  765. $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  766. [System.IO.File]::WriteAllText($startScriptPath, $startScriptContent, $utf8NoBom)
  767. Write-Log "Start script created: $startScriptPath" "INFO" Green
  768. Write-Log "Runtime log path: $runtimeLogPath" "INFO" Green
  769. Write-Log "Runtime error log path: $runtimeErrorLogPath" "INFO" Green
  770. # ------------------------------------------------------------
  771. # Optional Windows Service registration
  772. # ------------------------------------------------------------
  773. $registerService = (-not $NoService) -and (Read-YesNo -Question "Register Bambuddy as a Windows Service?" -DefaultYes $true)
  774. if ($registerService) {
  775. Register-BambuddyService `
  776. -ServiceName "Bambuddy" `
  777. -StartScriptPath $startScriptPath `
  778. -InstallDir $installDir `
  779. -BambuddyDir $bambuddyDir `
  780. -RuntimeLogPath $runtimeLogPath `
  781. -RuntimeErrorLogPath $runtimeErrorLogPath `
  782. -DataDir $dataDir `
  783. -LogDir $appLogDir
  784. }
  785. # ------------------------------------------------------------
  786. # Summary
  787. # ------------------------------------------------------------
  788. Write-Host ""
  789. Write-Host "=== Installation completed ===" -ForegroundColor Green
  790. Write-Host "Install directory: $installDir"
  791. Write-Host "Repository path: $bambuddyDir"
  792. Write-Host "Data directory: $dataDir"
  793. Write-Host "App log directory: $appLogDir"
  794. Write-Host "Port: $port"
  795. Write-Host "Bind address: $bindAddress"
  796. Write-Host "Installer log: $script:LogFile"
  797. Write-Host "Service stdout: $runtimeLogPath"
  798. Write-Host "Service stderr: $runtimeErrorLogPath"
  799. Write-Host "Start script: $startScriptPath"
  800. Write-Host ""
  801. Write-Host "Manual start:"
  802. Write-Host "powershell.exe -ExecutionPolicy Bypass -File `"$startScriptPath`""
  803. Write-Host ""
  804. Write-Host "Service commands:"
  805. Write-Host "Start-Service Bambuddy"
  806. Write-Host "Stop-Service Bambuddy"
  807. Write-Host "Restart-Service Bambuddy"
  808. Write-Host "Get-Service Bambuddy"
  809. Write-Host ""
  810. # ------------------------------------------------------------
  811. # Start manually if service was not registered
  812. # ------------------------------------------------------------
  813. if ((-not $registerService) -and (-not $NoStart) -and (-not $script:Yes) -and (-not $script:Silent)) {
  814. $startNow = Read-YesNo -Question "Start Bambuddy now?" -DefaultYes $true
  815. if ($startNow) {
  816. Write-Log "Starting Bambuddy manually..." "INFO" Green
  817. Write-Host "Local URL: http://localhost:$port"
  818. if ($bindAddress -eq "0.0.0.0") {
  819. Write-Host "Network URL: http://<this-computer-ip>:$port"
  820. }
  821. Write-Host "Press CTRL+C to stop Bambuddy."
  822. Write-Host ""
  823. Set-Location $bambuddyDir
  824. $env:DATA_DIR = $dataDir
  825. $env:LOG_DIR = $appLogDir
  826. & $venvPython -m uvicorn backend.app.main:app --host $bindAddress --port $port
  827. }
  828. }
  829. # When relaunched elevated via Restart-AsAdmin, the new PowerShell window
  830. # closes the moment the script returns. Pause so the install summary is
  831. # readable. Matches the catch-block gate below.
  832. if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) {
  833. Write-Host ""
  834. Read-Host "Press Enter to close"
  835. }
  836. }
  837. catch {
  838. Write-Host ""
  839. Write-Host "ERROR:" -ForegroundColor Red
  840. Write-Host $_.Exception.Message -ForegroundColor Red
  841. if ($script:LogFile) {
  842. Add-Content -Path $script:LogFile -Value "[ERROR] $($_.Exception.Message)`n$($_.ScriptStackTrace)" -Encoding UTF8
  843. Write-Host ""
  844. Write-Host "Installer log: $script:LogFile" -ForegroundColor Yellow
  845. }
  846. if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) {
  847. Write-Host ""
  848. Read-Host "Press Enter to close"
  849. }
  850. exit 1
  851. }