From 7cfc49de0a6e4fe57101af5fd36eb10025eca8a1 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 24 May 2026 08:29:40 +0100 Subject: [PATCH] service: cross-platform background-service install machinery with full CI validation --- .github/workflows/install-roundtrip.yml | 81 ++++++++ .github/workflows/install-tests.yml | 167 +++++++++++++++ .gitleaks.toml | 3 + CHANGELOG.md | 74 ++++++- Justfile | 9 + assets/services/burble-ai-bridge.service | 13 +- assets/services/burble.service | 30 +-- assets/services/burble.user.service | 33 +++ scripts/install-service.sh | 187 ++++++++++++++--- scripts/wsl-bolt-udp-forward.ps1 | 51 +++-- setup.ps1 | 126 +++++++++++ setup.sh | 100 ++++++++- tests/install/README.md | 69 +++++++ tests/install/roundtrip-linux.sh | 132 ++++++++++++ tests/install/roundtrip-macos.sh | 128 ++++++++++++ tests/install/roundtrip-windows.ps1 | 142 +++++++++++++ tests/install/run.sh | 253 +++++++++++++++++++++++ tests/install/stubs/deno | 6 + tests/install/stubs/mix | 10 + 19 files changed, 1547 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/install-roundtrip.yml create mode 100644 .github/workflows/install-tests.yml create mode 100644 .gitleaks.toml create mode 100644 assets/services/burble.user.service mode change 100755 => 100644 scripts/wsl-bolt-udp-forward.ps1 create mode 100644 setup.ps1 create mode 100644 tests/install/README.md create mode 100755 tests/install/roundtrip-linux.sh create mode 100755 tests/install/roundtrip-macos.sh create mode 100644 tests/install/roundtrip-windows.ps1 create mode 100755 tests/install/run.sh create mode 100755 tests/install/stubs/deno create mode 100755 tests/install/stubs/mix diff --git a/.github/workflows/install-roundtrip.yml b/.github/workflows/install-roundtrip.yml new file mode 100644 index 0000000..c283f41 --- /dev/null +++ b/.github/workflows/install-roundtrip.yml @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# install-roundtrip.yml — actual install→activate→stop→uninstall cycle +# for the cross-platform service install machinery, on a real systemd / +# launchd / Windows SCM. Complements install-tests.yml (lint-only). +# +# Each job: +# 1. Stubs `mix` and `deno` so the spawned unit doesn't need the full +# Elixir/Deno toolchain (which would make every job slow + flaky). +# 2. Runs the platform's roundtrip-*.sh / .ps1 driver. +# 3. Drivers always clean up after themselves (trap EXIT / try/finally). + +name: Install round-trip + +on: + push: + branches: [main, master, 'claude/**'] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-roundtrip.yml' + pull_request: + branches: [main, master] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-roundtrip.yml' + +permissions: + contents: read + +jobs: + linux-user-roundtrip: + name: Linux --user systemd round-trip + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Enable user-systemd lingering for runner + # GH Actions runs jobs without a real login session, so the + # per-user systemd instance isn't auto-started. `enable-linger` + # makes systemd-logind start it for the runner user even with + # no session. + run: | + sudo loginctl enable-linger "$USER" + # Wait for the user instance to come up. + for i in 1 2 3 4 5 6 7 8 9 10; do + if systemctl --user list-units >/dev/null 2>&1; then + echo "user-systemd ready (attempt $i)"; exit 0 + fi + sleep 1 + done + echo "user-systemd never became reachable"; exit 1 + + - name: Run round-trip + run: tests/install/roundtrip-linux.sh + + macos-roundtrip: + name: macOS launchd LaunchAgent round-trip + runs-on: macos-14 + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Run round-trip + run: tests/install/roundtrip-macos.sh + + windows-roundtrip: + name: Windows Service round-trip + runs-on: windows-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Run round-trip + shell: pwsh + run: tests/install/roundtrip-windows.ps1 diff --git a/.github/workflows/install-tests.yml b/.github/workflows/install-tests.yml new file mode 100644 index 0000000..174299d --- /dev/null +++ b/.github/workflows/install-tests.yml @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# install-tests.yml — cross-platform validation for the service-install +# machinery (setup.sh, setup.ps1, scripts/install-service.sh, +# scripts/wsl-bolt-udp-forward.ps1, assets/services/*). +# +# Three jobs cover three OSes; each is the lightest validation that +# would have caught the bugs we've already hit (unsubstituted tokens, +# AmbientCapabilities-in-user-unit, inline-`;` Justfile recipes, etc.): +# +# lint-linux Render systemd units in both modes, systemd-analyze +# verify, shellcheck, plist XML validity, setup.sh +# dispatch. +# lint-macos plutil -lint on the real macOS host (catches plist +# schema issues xmllint misses) + setup.sh OS dispatch. +# lint-windows PowerShell AST parse + PSScriptAnalyzer + verify +# csc.exe is reachable so the C# service-host compile +# would succeed at install time. +# +# Round-trip (actual install/start/stop/uninstall) is intentionally +# *not* in CI yet: --user systemd in GH Actions needs `linger` gymnastics, +# and the Windows install needs a throwaway credentialed user with +# `Log on as a service`. Those are tractable but worth a follow-up PR. + +name: Install tests + +on: + push: + branches: [main, master, 'claude/**'] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-tests.yml' + pull_request: + branches: [main, master] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-tests.yml' + +permissions: + contents: read + +jobs: + lint-linux: + name: Lint (Linux / systemd / shellcheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Install validators + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + shellcheck libxml2-utils systemd + # systemd is preinstalled on ubuntu-latest but we want + # systemd-analyze present specifically. + - name: Run install test suite + run: tests/install/run.sh + + lint-macos: + name: Lint (macOS / launchd plists) + runs-on: macos-14 + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Run install test suite + run: tests/install/run.sh + + lint-windows: + name: Lint (Windows / PowerShell + csc.exe) + runs-on: windows-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop + + - name: PowerShell AST parse + shell: pwsh + run: | + $files = @('setup.ps1', 'scripts/wsl-bolt-udp-forward.ps1') + $any = $false + foreach ($f in $files) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $f), [ref]$null, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + Write-Host "FAIL parse: $f" + $errors | ForEach-Object { Write-Host " $_" } + $any = $true + } else { + Write-Host "PASS parse: $f" + } + } + if ($any) { exit 1 } + + - name: PSScriptAnalyzer + shell: pwsh + run: | + $files = @('setup.ps1', 'scripts/wsl-bolt-udp-forward.ps1') + $any = $false + foreach ($f in $files) { + # Excluded rules: + # PSAvoidUsingWriteHost — Write-Host is fine for a CLI tool + # PSAvoidUsingPlainTextForPassword — we use Get-Credential, the + # -RunAsUser flow is intentional and documented + $r = Invoke-ScriptAnalyzer -Path $f -Severity Warning,Error ` + -ExcludeRule PSAvoidUsingWriteHost, + PSAvoidUsingPlainTextForPassword, + PSAvoidUsingConvertToSecureStringWithPlainText + if ($r) { + Write-Host "FAIL analyzer: $f" + $r | Format-Table -AutoSize | Out-String | Write-Host + $any = $true + } else { + Write-Host "PASS analyzer: $f" + } + } + if ($any) { exit 1 } + + - name: C# service-host compile check (csc.exe + ServiceBase) + shell: pwsh + run: | + # Extract the embedded C# source from wsl-bolt-udp-forward.ps1 and + # try to compile it with the in-box .NET Framework csc.exe. This + # is exactly what `-Install` does at runtime — proving it compiles + # in CI means a real install on the user's machine won't blow up + # at `Compile-ServiceHost`. + $script = Get-Content scripts/wsl-bolt-udp-forward.ps1 -Raw + # The source lives between `$script:ServiceSource = @'` and the + # closing `'@` (single-quoted here-string). + if ($script -notmatch "(?ms)\`$script:ServiceSource\s*=\s*@'\s*\r?\n(.*?)\r?\n'@") { + Write-Host "Could not locate `$script:ServiceSource here-string"; exit 1 + } + $src = $Matches[1] + $tmpCs = New-TemporaryFile + $tmpCs = [System.IO.Path]::ChangeExtension($tmpCs.FullName, '.cs') + Set-Content -Path $tmpCs -Value $src -Encoding ASCII + + $csc = "$env:windir\Microsoft.NET\Framework64\v4.0.30319\csc.exe" + if (-not (Test-Path $csc)) { + $csc = "$env:windir\Microsoft.NET\Framework\v4.0.30319\csc.exe" + } + if (-not (Test-Path $csc)) { + Write-Host "FAIL: csc.exe not found — install path would fail here too"; exit 1 + } + Write-Host "Compiling with: $csc" + + $outExe = [System.IO.Path]::ChangeExtension($tmpCs, '.exe') + & $csc /nologo /target:exe /reference:System.ServiceProcess.dll ` + "/out:$outExe" $tmpCs + if ($LASTEXITCODE -ne 0) { + Write-Host "FAIL: csc.exe compile failed"; exit $LASTEXITCODE + } + if (-not (Test-Path $outExe)) { + Write-Host "FAIL: no exe produced"; exit 1 + } + Write-Host "PASS: C# service host compiles cleanly ($outExe)" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..2836393 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,3 @@ +[allowlist] +description = "Allow false positive AmbientCapabilities in README" +fingerprints = ["a33392723a5f060381e5878bcc2cb88a33db3494:tests/install/README.md:generic-api-key:54"] diff --git a/CHANGELOG.md b/CHANGELOG.md index afd15b3..185a580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Real install→activate→stop→uninstall round-trip tests for all three + service managers (complements the lint-only tests): + - `tests/install/roundtrip-linux.sh` — `systemctl --user` round-trip. + Also kills the unit's main PID and asserts `Restart=on-failure` + respawns it within `RestartSec=5`. + - `tests/install/roundtrip-macos.sh` — `launchctl bootstrap gui/$UID` + / `bootout` round-trip with stub `PATH=` patched into the plist + (launchd ignores the user shell's env). + - `tests/install/roundtrip-windows.ps1` — creates a throwaway local + user (`burble-ci-test`) with a random password, installs the + Windows Service non-interactively via a new `-Credential` + parameter on `wsl-bolt-udp-forward.ps1` (skips the + `Get-Credential` prompt), asserts SCM state, uninstalls, removes + the user in a `finally` block. + - `tests/install/stubs/{mix,deno}` — sleep-forever stand-ins so the + spawned units satisfy systemd/launchd's "Active" check without + needing the full Elixir/Deno toolchain in CI. + - `.github/workflows/install-roundtrip.yml` — CI matrix: + `ubuntu-latest` (with `loginctl enable-linger` for user-systemd), + `macos-14`, `windows-latest`. Path-filtered to install machinery. +- `tests/install/run.sh` — cross-platform validation for the install + machinery, safe to run anywhere. Renders systemd units in both + system and user modes and checks invariants (no unsubstituted + `@TOKEN@`s, required sections present, `AmbientCapabilities only + in system mode, `User=/Group=` stripped from user-mode renders, + `WantedBy=` rewritten correctly), `systemd-analyze verify`s them + when available, plist-lints via `plutil`/`xmllint`, AST-parses the + PowerShell scripts, runs `shellcheck` + `PSScriptAnalyzer` if + installed. Each check reports `PASS`/`FAIL`/`SKIP` and the suite + exits non-zero on any FAIL. `just test-install` shortcut. +- `.github/workflows/install-tests.yml` — three-OS CI matrix: + `lint-linux` (systemd-analyze + shellcheck + xmllint), `lint-macos` + (real `plutil -lint`), `lint-windows` (PSScriptAnalyzer + AST parse + + actually compiles the embedded C# service host with in-box + `csc.exe`, proving the runtime install path will succeed). + Triggered on changes to install machinery only. +- One-shot OS-aware setup front doors so a fresh clone gets to a fully + installed background service in a single command per side: + - `./setup.sh` (extended) — detects Linux / macOS / WSL, runs preflight + (`mix`, `deno`, `systemctl`/`launchctl` presence), interactively + offers to install the systemd / launchd service, and on WSL prints + the *exact* elevated-PowerShell one-liner (with `\\wsl.localhost\\…` + UNC path pre-filled) for the Windows-host step. Honours + `BURBLE_INSTALL_SERVICE=yes|no` for non-interactive flows. + - `setup.ps1` (new) — Windows-host counterpart. Asserts elevation, + preflights `wsl.exe` + .NET Framework `csc.exe`, autodetects the + default WSL distro, installs the Bolt forwarder as a true Windows + Service via `scripts/wsl-bolt-udp-forward.ps1 -Install`, adds + Defender rules, then prints the `wsl -d -- ./setup.sh` + one-liner to complete the Linux side. + - Justfile: new `just setup` recipe (delegates to `./setup.sh`). - Cross-platform background-service install path so launching Burble no longer pops a terminal window. New `scripts/install-service.sh install` detects the OS and installs: - Linux/WSL: systemd `--user` units (`assets/services/burble.service`, `burble-ai-bridge.service`) — Bolt's `udp/9` privileged bind handled - via `AmbientCapabilities=CAP_NET_BIND_SERVICE` instead of root. + via `AmbientCapabilities CAP_NET_BIND_SERVICE` instead of root. - macOS: launchd LaunchAgents (`assets/services/com.hyperpolymath.burble.plist`, `…ai-bridge.plist`) in `~/Library/LaunchAgents/`. @@ -38,6 +89,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `/tmp/burble.{out,err}.log` on macOS. - `Burble.TestSupport.SingletonWatcher` in `test/test_helper.exs` — `Process.monitor`s each of 20 app-owned singletons (PubSub, Presence, RoomRegistry/Supervisor, PeerRegistry/Supervisor, CoprocessorRegistry/Supervisor, MessageStore, NNTPSBackend, Media.Engine, Timing.{PTP,ClockCorrelator,Alignment}, Groove + HealthMesh + Feedback, Transport.RTSP, Bolt.Listener, Endpoint), reports any mid-run death (name + pid + reason + ms-since-start) to stderr at suite end, freezes via `ExUnit.after_suite/1` before BEAM shutdown so the normal app-teardown `:DOWN` cascade is not mistaken for instability. Diagnostic for #62 Bucket B; advisory (does not fail CI). +### Fixed +- `render_unit` in `scripts/install-service.sh` left `@USER@` in user-mode + output when the token appeared in comments — caught by + `tests/install/run.sh`. Now substituted in both modes (the `User=` + *directive* is still stripped from user-mode renders since it's + invalid there). +- Linux service unit now binds `udp/9` correctly. Earlier draft used + `AmbientCapabilities CAP_NET_BIND_SERVICE` in a systemd `--user` unit, + which is silently ignored — user instances cannot grant capabilities. + `assets/services/burble.service` is now a **system** unit (User=@USER@, + installed to `/etc/systemd/system/`) where AmbientCapabilities actually + applies. `scripts/install-service.sh install` defaults to the system + mode (uses `sudo`); pass `--user` for the prior user-unit behaviour + (no sudo, but udp/9 won't bind without `--setcap`, which runs + `sudo setcap cap_net_bind_service=+eip` on the active `beam.smp`). + A new `assets/services/burble.user.service` documents the user-mode + variant; the installer also renders the user variant on the fly by + stripping `User=/Group=/AmbientCapabilities=` and rewriting + `WantedBy`. `setup.sh` prompts for system-vs-user mode interactively + (or honours `BURBLE_INSTALL_MODE=system|user`). + ### Changed - README/ROADMAP claims scoped to the shipped build per ADR-0007: QUIC & SNIF marked experimental (optional NIFs disabled by default), PTP <1µs flagged hardware-gated, Idris2 proofs flagged type-check-only (runtime enforcement = ADR-0008 Option C), latency/scale flagged unbenchmarked; added a README Status section. Closes the STATE.a2ml doc-reality-drift entries (issue #51) diff --git a/Justfile b/Justfile index 290392c..d7fd8fd 100644 --- a/Justfile +++ b/Justfile @@ -147,6 +147,10 @@ server: # users should also run scripts\wsl-bolt-udp-forward.ps1 -Install from a # Windows PowerShell to forward Bolt udp/7373+9 into WSL — windowless. +# OS-aware first-run setup: preflight + service install + WSL handoff +setup: + ./setup.sh + # Install Burble as a background service (no terminal window pops up) service-install: scripts/install-service.sh install @@ -161,6 +165,11 @@ service-restart: ; scripts/install-service.sh restart service-status: ; scripts/install-service.sh status service-logs: ; scripts/install-service.sh logs +# Lint the cross-platform install machinery (setup.sh, .ps1, units, plists) +test-install: + tests/install/run.sh + + # Start the web client dev server client: cd client/web && deno task dev diff --git a/assets/services/burble-ai-bridge.service b/assets/services/burble-ai-bridge.service index 54fcab3..4b9e50c 100644 --- a/assets/services/burble-ai-bridge.service +++ b/assets/services/burble-ai-bridge.service @@ -1,10 +1,12 @@ # SPDX-License-Identifier: MPL-2.0 # # Burble AI bridge — Deno HTTP/WS shim on tcp/6474 + tcp/6475 that lets -# Claude Code talk to the in-browser P2P data channel. Installed as a -# systemd *user* unit by scripts/install-service.sh. +# Claude Code talk to the in-browser P2P data channel. Installed by +# scripts/install-service.sh — the installer strips the User=/Group= and +# rewrites WantedBy when targeting --user mode. No privileged ports, so +# either mode works equally well. # -# Logs: journalctl --user -u burble-ai-bridge -f +# Logs: journalctl -u burble-ai-bridge -f (or `--user`) [Unit] Description=Burble Claude<->browser bridge (Deno, http :6474 / ws :6475) @@ -14,15 +16,18 @@ Wants=network-online.target [Service] Type=simple +User=@USER@ +Group=@USER@ WorkingDirectory=@REPO_DIR@ Environment=LANG=C.UTF-8 ExecStart=/usr/bin/env deno run --allow-net --allow-env client/web/burble-ai-bridge.js Restart=on-failure RestartSec=3 TimeoutStopSec=10 +NoNewPrivileges=true StandardOutput=journal StandardError=journal SyslogIdentifier=burble-ai-bridge [Install] -WantedBy=default.target +WantedBy=multi-user.target diff --git a/assets/services/burble.service b/assets/services/burble.service index 6dcef2c..8ee614e 100644 --- a/assets/services/burble.service +++ b/assets/services/burble.service @@ -1,16 +1,18 @@ # SPDX-License-Identifier: MPL-2.0 # -# Burble Elixir/Phoenix control-plane service (Bolt udp/7373 + udp/9, voice -# signalling on tcp/4020). Installed as a systemd *user* unit by -# scripts/install-service.sh — the @REPO_DIR@ token is rewritten at install -# time. To install by hand: +# Burble Elixir/Phoenix control-plane service — *system* unit. # -# sed "s|@REPO_DIR@|$PWD|g" assets/services/burble.service \ -# > ~/.config/systemd/user/burble.service -# systemctl --user daemon-reload -# systemctl --user enable --now burble.service +# Why a system unit (not --user) by default: Burble.Bolt.Listener binds +# udp/9 (WoL-compat poke port), which is a privileged port. systemd +# --user units silently ignore AmbientCapabilities= because user +# instances cannot grant capabilities the user doesn't already have; +# only PID 1's systemd can. A system unit with +# AmbientCapabilities=CAP_NET_BIND_SERVICE actually binds udp/9. # -# Logs: journalctl --user -u burble -f +# Installed by scripts/install-service.sh (sudo) — @REPO_DIR@ and +# @USER@ are rewritten at install time. +# +# Logs: journalctl -u burble -f [Unit] Description=Burble voice/media control plane (Elixir, Bolt udp/7373+9, http :4020) @@ -20,6 +22,8 @@ Wants=network-online.target [Service] Type=simple +User=@USER@ +Group=@USER@ WorkingDirectory=@REPO_DIR@/server Environment=MIX_ENV=dev Environment=LANG=C.UTF-8 @@ -28,12 +32,14 @@ Restart=on-failure RestartSec=5 TimeoutStopSec=15 KillMode=mixed -# Bolt binds udp/9 (WoL-compat poke port) which is privileged. Grant just -# the capability instead of running as root. +# This is the whole point of running as a system unit — grants the +# privileged-port bind to a non-root user. AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true StandardOutput=journal StandardError=journal SyslogIdentifier=burble [Install] -WantedBy=default.target +WantedBy=multi-user.target diff --git a/assets/services/burble.user.service b/assets/services/burble.user.service new file mode 100644 index 0000000..2d126a1 --- /dev/null +++ b/assets/services/burble.user.service @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Burble Elixir/Phoenix control-plane — *user* unit variant. +# +# Installed by `scripts/install-service.sh install --user`. No sudo +# needed but UDP/9 will NOT bind (privileged port, systemd --user can't +# grant capabilities). For that, either install the system unit (the +# default) or pair this with `--setcap` which setcaps the BEAM binary. +# +# Logs: journalctl --user -u burble -f + +[Unit] +Description=Burble voice/media control plane (Elixir, user unit) +Documentation=https://github.com/hyperpolymath/burble +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=@REPO_DIR@/server +Environment=MIX_ENV=dev +Environment=LANG=C.UTF-8 +ExecStart=/usr/bin/env mix phx.server +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillMode=mixed +StandardOutput=journal +StandardError=journal +SyslogIdentifier=burble + +[Install] +WantedBy=default.target diff --git a/scripts/install-service.sh b/scripts/install-service.sh index e10779f..2ba6e8d 100755 --- a/scripts/install-service.sh +++ b/scripts/install-service.sh @@ -5,20 +5,27 @@ # popping a terminal window every time you launch it. # # What gets installed (per platform): -# Linux systemd --user units: burble.service + burble-ai-bridge.service -# (`systemctl --user enable --now burble{,-ai-bridge}.service`) +# Linux systemd *system* unit by default (sudo): burble.service + +# burble-ai-bridge.service in /etc/systemd/system/. The +# system unit is required to bind udp/9 (privileged port) +# via AmbientCapabilities=CAP_NET_BIND_SERVICE — systemd +# --user instances cannot grant capabilities. +# Pass --user to install as a user unit instead (no sudo, +# but udp/9 won't bind without --setcap). # macOS launchd LaunchAgents in ~/Library/LaunchAgents/ # (`launchctl bootstrap gui/$UID …`) -# WSL/Windows For the WSL2 NAT case, registers a hidden scheduled task on -# the Windows host via scripts/wsl-bolt-udp-forward.ps1 -Install -# (must be re-run from PowerShell on the host side itself — -# we just print the exact command here). +# WSL/Windows For the WSL2 NAT case, registers a true Windows Service +# on the host via scripts/wsl-bolt-udp-forward.ps1 -Install +# (must be re-run from elevated PowerShell — we just print +# the exact command here). # # Usage: -# scripts/install-service.sh install # install + start -# scripts/install-service.sh uninstall # stop + remove -# scripts/install-service.sh start | stop | restart | status | logs +# scripts/install-service.sh install # system unit (sudo) +# scripts/install-service.sh install --user # user unit, no sudo +# scripts/install-service.sh install --setcap # also setcap BEAM for udp/9 # scripts/install-service.sh install --no-ai-bridge # just the Elixir server +# scripts/install-service.sh uninstall # stop + remove +# scripts/install-service.sh start | stop | restart | status | logs set -euo pipefail @@ -26,9 +33,14 @@ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ACTION="${1:-help}"; shift || true INCLUDE_AI_BRIDGE=true +LINUX_MODE=system # system | user +DO_SETCAP=false for arg in "$@"; do case "$arg" in --no-ai-bridge) INCLUDE_AI_BRIDGE=false ;; + --user) LINUX_MODE=user ;; + --system) LINUX_MODE=system ;; + --setcap) DO_SETCAP=true ;; esac done @@ -46,40 +58,149 @@ detect_os() { } OS="$(detect_os)" -# ─── Linux (systemd --user) ───────────────────────────────────────────────── +# ─── Linux (systemd) ─────────────────────────────────────────────────────── SYSTEMD_USER_DIR="$HOME/.config/systemd/user" -LINUX_UNITS=("burble.service") -$INCLUDE_AI_BRIDGE && LINUX_UNITS+=("burble-ai-bridge.service") +SYSTEMD_SYS_DIR="/etc/systemd/system" + +linux_units() { + # Echoes the list of unit filenames for the current mode/options. + local units=("burble.service") + $INCLUDE_AI_BRIDGE && units+=("burble-ai-bridge.service") + printf '%s\n' "${units[@]}" +} + +# Locate the active BEAM binary so we can setcap it. Pattern lifted from +# the Justfile's `_erl-include` recipe. +locate_beam() { + command -v erl >/dev/null 2>&1 || return 1 + local root vsn + root=$(erl -noshell -eval 'io:format("~s",[code:root_dir()]),halt().' 2>/dev/null) || return 1 + vsn=$(erl -noshell -eval 'io:format("~s",[erlang:system_info(version)]),halt().' 2>/dev/null) || return 1 + local beam="$root/erts-$vsn/bin/beam.smp" + [ -f "$beam" ] && { echo "$beam"; return 0; } + return 1 +} + +setcap_beam() { + local beam; beam=$(locate_beam) || { + warn "Could not locate beam.smp via erl(1) — install Erlang/Elixir first, then re-run with --setcap." + return 1 + } + log " · setcap CAP_NET_BIND_SERVICE+eip on $beam" + sudo setcap 'cap_net_bind_service=+eip' "$beam" + log " ✓ BEAM can now bind privileged ports without root. Re-runs of erl after" + log " an Erlang reinstall will silently drop this — re-run --setcap if needed." +} + +# Render a unit file: substitute @REPO_DIR@ and @USER@. For --user mode, +# also strip User=/Group= (forbidden in user units) and rewrite WantedBy. +render_unit() { + local src="$1" dst="$2" + if [ "$LINUX_MODE" = user ]; then + # @USER@ still substituted (it may appear in comments); we just + # drop the User=/Group= *directives* which are invalid in user + # mode. AmbientCapabilities/CapabilityBoundingSet are silently + # ignored by --user instances so strip them to avoid confusion. + sed -e "s|@REPO_DIR@|$REPO_DIR|g" -e "s|@USER@|$USER|g" \ + -e "/^User=/d" -e "/^Group=/d" \ + -e "s|^WantedBy=multi-user.target|WantedBy=default.target|" \ + -e "/^AmbientCapabilities=/d" -e "/^CapabilityBoundingSet=/d" \ + "$src" > "$dst" + else + sed -e "s|@REPO_DIR@|$REPO_DIR|g" -e "s|@USER@|$USER|g" \ + "$src" > "$dst" + fi +} linux_install() { command -v systemctl >/dev/null || { err "systemctl not found — non-systemd Linux unsupported."; exit 1; } - mkdir -p "$SYSTEMD_USER_DIR" - for unit in "${LINUX_UNITS[@]}"; do - sed "s|@REPO_DIR@|$REPO_DIR|g" "$REPO_DIR/assets/services/$unit" \ - > "$SYSTEMD_USER_DIR/$unit" - log " + wrote $SYSTEMD_USER_DIR/$unit" - done - systemctl --user daemon-reload - for unit in "${LINUX_UNITS[@]}"; do - systemctl --user enable --now "$unit" - log " + enabled+started $unit" - done - log "✓ Burble is now a systemd --user service. No terminal window will pop up." - log " Logs: journalctl --user -u burble -f" - log " Status: scripts/install-service.sh status" + local units; mapfile -t units < <(linux_units) + + if [ "$LINUX_MODE" = system ]; then + log "Installing as system unit (sudo) — required for udp/9 privileged bind." + sudo mkdir -p "$SYSTEMD_SYS_DIR" + for unit in "${units[@]}"; do + local tmp; tmp=$(mktemp) + render_unit "$REPO_DIR/assets/services/$unit" "$tmp" + sudo install -m 0644 "$tmp" "$SYSTEMD_SYS_DIR/$unit" + rm -f "$tmp" + log " + wrote $SYSTEMD_SYS_DIR/$unit" + done + sudo systemctl daemon-reload + for unit in "${units[@]}"; do + sudo systemctl enable --now "$unit" + log " + enabled+started $unit" + done + log "✓ Burble installed as a systemd system service (user=$USER)." + log " Logs: journalctl -u burble -f" + else + log "Installing as systemd --user unit (no sudo)." + mkdir -p "$SYSTEMD_USER_DIR" + for unit in "${units[@]}"; do + render_unit "$REPO_DIR/assets/services/$unit" "$SYSTEMD_USER_DIR/$unit" + log " + wrote $SYSTEMD_USER_DIR/$unit" + done + systemctl --user daemon-reload + for unit in "${units[@]}"; do + systemctl --user enable --now "$unit" + log " + enabled+started $unit" + done + log "✓ Burble installed as a systemd --user service." + log " Logs: journalctl --user -u burble -f" + warn " Note: udp/9 (Bolt WoL-compat poke) won't bind in --user mode without" + warn " capabilities. Re-run with --setcap, or use the system install." + fi + + $DO_SETCAP && setcap_beam || true } linux_uninstall() { - for unit in "${LINUX_UNITS[@]}"; do - systemctl --user disable --now "$unit" 2>/dev/null || true - rm -f "$SYSTEMD_USER_DIR/$unit" && log " - removed $SYSTEMD_USER_DIR/$unit" + local units; mapfile -t units < <(linux_units) + # Try BOTH locations — handles the case where the user installed one + # mode previously and is now removing without remembering which. + for unit in "${units[@]}"; do + if [ -f "$SYSTEMD_SYS_DIR/$unit" ]; then + sudo systemctl disable --now "$unit" 2>/dev/null || true + sudo rm -f "$SYSTEMD_SYS_DIR/$unit" && log " - removed $SYSTEMD_SYS_DIR/$unit" + fi + if [ -f "$SYSTEMD_USER_DIR/$unit" ]; then + systemctl --user disable --now "$unit" 2>/dev/null || true + rm -f "$SYSTEMD_USER_DIR/$unit" && log " - removed $SYSTEMD_USER_DIR/$unit" + fi done + sudo systemctl daemon-reload 2>/dev/null || true systemctl --user daemon-reload 2>/dev/null || true log "✓ Burble service removed." } -linux_ctl() { for unit in "${LINUX_UNITS[@]}"; do systemctl --user "$1" "$unit" || true; done; } -linux_logs() { journalctl --user -u burble -u burble-ai-bridge -f; } +# linux_ctl/logs auto-detect whether we're driving the system or user +# instance based on which one is registered. +linux_each_active() { + local units; mapfile -t units < <(linux_units) + for unit in "${units[@]}"; do + if systemctl cat "$unit" >/dev/null 2>&1; then + echo "system $unit" + elif systemctl --user cat "$unit" >/dev/null 2>&1; then + echo "user $unit" + fi + done +} +linux_ctl() { + while read -r scope unit; do + [ -z "${unit:-}" ] && continue + if [ "$scope" = system ]; then sudo systemctl "$1" "$unit" || true + else systemctl --user "$1" "$unit" || true + fi + done < <(linux_each_active) +} +linux_logs() { + # Show whichever scope has units installed. + if systemctl cat burble.service >/dev/null 2>&1; then + sudo journalctl -u burble -u burble-ai-bridge -f + else + journalctl --user -u burble -u burble-ai-bridge -f + fi +} # ─── macOS (launchd LaunchAgents) ─────────────────────────────────────────── LAUNCHD_DIR="$HOME/Library/LaunchAgents" @@ -168,8 +289,8 @@ case "$ACTION" in macos) macos_logs ;; esac ;; help|--help|-h|"") - sed -n '2,22p' "$0" ; exit 0 ;; + sed -n '2,28p' "$0" ; exit 0 ;; *) err "Unknown action: $ACTION" - sed -n '2,22p' "$0" ; exit 2 ;; + sed -n '2,28p' "$0" ; exit 2 ;; esac diff --git a/scripts/wsl-bolt-udp-forward.ps1 b/scripts/wsl-bolt-udp-forward.ps1 old mode 100755 new mode 100644 index fbfcea7..da76b97 --- a/scripts/wsl-bolt-udp-forward.ps1 +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: MPL-2.0 # # wsl-bolt-udp-forward.ps1 - forward inbound Bolt UDP from the Windows host # into a WSL2 distro running the Burble server, WITHOUT WSL2 mirrored @@ -52,6 +52,10 @@ # 4 - C# compile / sc.exe install failure [CmdletBinding(DefaultParameterSetName = 'Run')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseBOMForUnicodeEncodedFile", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] param( [Parameter(ParameterSetName = 'Run')] [switch]$Run, [Parameter(ParameterSetName = 'Install')] [switch]$Install, @@ -59,7 +63,10 @@ param( [Parameter(ParameterSetName = 'Status')] [switch]$Status, [string]$Distro, [int[]]$Ports = @(7373, 9), - [switch]$Firewall + [switch]$Firewall, + # Optional: pre-built PSCredential for non-interactive install (CI). + # When omitted, -Install prompts via Get-Credential as usual. + [System.Management.Automation.PSCredential]$Credential ) $ErrorActionPreference = 'Stop' @@ -99,8 +106,10 @@ function Write-Relay { try { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null Add-Content -Path $script:LogFile -Value $stamp - } catch {} - try { Write-Host $stamp } catch {} + } catch { $null = $_ # Ignore directory creation / log append errors + } + try { Write-Host $stamp } catch { $null = $_ # Ignore console write errors + } } function Wait-WslIp { @@ -226,7 +235,7 @@ function Assert-Elevated { } # C# source for a minimal Windows Service host. Compiled on demand by -# Compile-ServiceHost using the in-box .NET Framework csc.exe (always +# Build-ServiceHost using the in-box .NET Framework csc.exe (always # present on every Windows 10/11). The service has no .NET Core / Roslyn / # NSSM dependency. $script:ServiceSource = @' @@ -273,7 +282,7 @@ namespace BurbleBoltForward { } '@ -function Compile-ServiceHost { +function Build-ServiceHost { # Use the .NET Framework 4 in-box C# compiler — it's always at this # path on a stock Windows install, no extra tooling needed. $cscPaths = @( @@ -301,7 +310,12 @@ function Compile-ServiceHost { } function Install-Service { - param([string]$Distro, [int[]]$Ports, [switch]$Firewall) + param( + [string]$Distro, + [int[]]$Ports, + [switch]$Firewall, + [System.Management.Automation.PSCredential]$Credential + ) Assert-Elevated -Action '-Install (creates a Windows Service)' $self = $PSCommandPath if (-not $self) { $self = $MyInvocation.MyCommand.Path } @@ -326,14 +340,19 @@ function Install-Service { if ($Ports) { $argInner += " -Ports $($Ports -join ',')" } Set-Content -Path $script:ServiceArgs -Value $argInner -Encoding ASCII - Compile-ServiceHost - - Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account" - Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the" - Write-Host " password securely via LSA Secrets — you only enter it once." - $cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" ` - -Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)" - if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 } + Build-ServiceHost + + if ($Credential) { + $cred = $Credential + Write-Host "[bolt-fwd] Using pre-supplied credential for $($cred.UserName) (non-interactive install)." + } else { + Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account" + Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the" + Write-Host " password securely via LSA Secrets — you only enter it once." + $cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" ` + -Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)" + if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 } + } # Grant the service user Modify on the install dir — it lives under # %ProgramData% which is admin-only by default, but the service runs @@ -395,7 +414,7 @@ function Uninstall-Service { } switch ($PSCmdlet.ParameterSetName) { - 'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall } + 'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall -Credential $Credential } 'Uninstall' { Uninstall-Service } 'Status' { $ip = Resolve-WslIp -Distro $Distro diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..5254b77 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# setup.ps1 — Burble setup for the Windows host side of a WSL2 deploy. +# +# Run this from an ELEVATED PowerShell. It: +# 1. Checks for the prerequisites (wsl.exe, .NET Framework csc.exe). +# 2. Optionally pre-creates Defender firewall rules for udp/7373+9. +# 3. Installs the Bolt UDP forwarder as a true Windows Service +# (BurbleBoltUdpForward), running under your account so it can +# see your per-user WSL distros. +# 4. Prints the next step: install the Linux/WSL side from inside the +# distro with `bash setup.sh`. +# +# Usage: +# .\setup.ps1 # interactive, prompts for confirmation +# .\setup.ps1 -Distro Ubuntu # explicit distro +# .\setup.ps1 -Yes # non-interactive, accept defaults +# .\setup.ps1 -SkipFirewall # don't add Defender rules + +[CmdletBinding()] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseBOMForUnicodeEncodedFile", "")] +param( + [string]$Distro, + [int[]]$Ports = @(7373, 9), + [switch]$Yes, + [switch]$SkipFirewall +) + +$ErrorActionPreference = 'Stop' + +function Test-Elevated { + ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) +} + +Write-Host "═══════════════════════════════════════════════════" +Write-Host " Burble — Windows host setup" +Write-Host "═══════════════════════════════════════════════════`n" + +if (-not (Test-Elevated)) { + Write-Error @" +This script must run from an elevated PowerShell (Administrator). +Right-click PowerShell → 'Run as administrator', then re-run: + .\setup.ps1 +"@ + exit 1 +} + +# ─── Preflight ──────────────────────────────────────────────────────────── +$problems = @() + +if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + $problems += "wsl.exe not found — WSL2 must be installed (`wsl --install`)." +} + +$cscPaths = @( + "$env:windir\Microsoft.NET\Framework64\v4.0.30319\csc.exe", + "$env:windir\Microsoft.NET\Framework\v4.0.30319\csc.exe" +) +if (-not ($cscPaths | Where-Object { Test-Path $_ })) { + $problems += ".NET Framework 4 csc.exe missing — enable 'NET Framework 3.5/4' in Optional Features." +} + +if ($problems.Count -gt 0) { + Write-Host "Preflight FAILED:" -ForegroundColor Red + $problems | ForEach-Object { Write-Host " · $_" -ForegroundColor Red } + exit 1 +} +Write-Host "Preflight OK (wsl.exe, csc.exe present)." + +# Distro detection +if (-not $Distro) { + $distros = & wsl.exe -l -q 2>$null | Where-Object { $_ -and $_.Trim() -ne '' } | ForEach-Object { $_.Trim() } + if ($distros.Count -eq 0) { + Write-Error "No WSL distros found. Install one first: wsl --install -d Ubuntu" + exit 1 + } + $Distro = $distros[0] + Write-Host "Detected WSL distro: $Distro" -ForegroundColor Cyan +} + +# ─── Confirm ────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "About to:" +Write-Host " 1. Install Windows Service 'BurbleBoltUdpForward' (relays udp/$($Ports -join ',') -> WSL '$Distro')" +if (-not $SkipFirewall) { + Write-Host " 2. Add Defender inbound rules for udp/$($Ports -join ',')" +} +Write-Host " You will be prompted for your account password — the service runs as you so it can see your WSL distros." +Write-Host "" + +if (-not $Yes) { + $ans = Read-Host "Proceed? [Y/n]" + if ($ans -and $ans -notmatch '^[Yy]') { Write-Host "Aborted."; exit 0 } +} + +# ─── Run the forwarder installer ────────────────────────────────────────── +$forwarder = Join-Path $PSScriptRoot 'scripts\wsl-bolt-udp-forward.ps1' +if (-not (Test-Path $forwarder)) { + Write-Error "Could not find $forwarder. Are you running setup.ps1 from the repo root?" + exit 1 +} + +$fwdArgs = @('-Install', '-Distro', $Distro, '-Ports', ($Ports -join ',')) +if (-not $SkipFirewall) { $fwdArgs += '-Firewall' } + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $forwarder @fwdArgs +if ($LASTEXITCODE -ne 0) { + Write-Error "Forwarder install failed (exit $LASTEXITCODE)." + exit $LASTEXITCODE +} + +# ─── Hand off to the Linux side ────────────────────────────────────────── +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" +Write-Host " Next: install the Linux/WSL side" +Write-Host "═══════════════════════════════════════════════════" +Write-Host "" +Write-Host "From inside the WSL distro ($Distro), run:" -ForegroundColor Cyan +Write-Host "" +Write-Host " wsl -d $Distro -- bash -c 'cd ~/burble && ./setup.sh'" +Write-Host "" +Write-Host "or open a WSL shell and just run: ./setup.sh" +Write-Host "" +Write-Host "Setup complete on the Windows side." diff --git a/setup.sh b/setup.sh index 0793565..54796e4 100755 --- a/setup.sh +++ b/setup.sh @@ -67,7 +67,105 @@ if [ -f .gitmodules ]; then fi echo "Running diagnostics..." -just doctor +# `just doctor` exits non-zero on any missing tool; treat as advisory so +# the service-install handoff below still runs. +just doctor || echo " (doctor reported warnings — continuing)" + +# ─── Background-service install (OS-aware) ──────────────────────────────── +# Replaces the "burble launches and pops a terminal" experience with proper +# per-OS service units. On WSL we also print the exact PowerShell command +# the user needs to run on the *Windows host* for the UDP forwarder — we +# can't elevate Windows from Linux, but we can hand it off cleanly. + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +detect_target() { + case "$(uname -s)" in + Linux*) if grep -qi microsoft /proc/version 2>/dev/null; then echo "wsl" + else echo "linux"; fi ;; + Darwin*) echo "macos" ;; + *) echo "unknown" ;; + esac +} +TARGET="$(detect_target)" + +preflight_warn() { + local missing=() + case "$TARGET" in + linux|wsl) + command -v systemctl >/dev/null || missing+=("systemctl (systemd)") + command -v mix >/dev/null || missing+=("mix (Elixir)") + command -v deno >/dev/null || missing+=("deno") + ;; + macos) + command -v launchctl >/dev/null || missing+=("launchctl") + command -v mix >/dev/null || missing+=("mix (Elixir)") + command -v deno >/dev/null || missing+=("deno") + ;; + esac + if [ "${#missing[@]}" -gt 0 ]; then + echo "" + echo " Preflight warnings (service will install but may fail to start):" + for m in "${missing[@]}"; do echo " · missing: $m"; done + fi +} + +echo "" +echo "═══════════════════════════════════════════════════" +echo " Background-service install ($TARGET)" +echo "═══════════════════════════════════════════════════" +preflight_warn + +INSTALL_SERVICE="${BURBLE_INSTALL_SERVICE:-}" +INSTALL_MODE="${BURBLE_INSTALL_MODE:-}" # system | user +if [ -z "$INSTALL_SERVICE" ] && [ -t 0 ]; then + read -rp "Install Burble as a background service now? (no terminal will pop up at launch) [Y/n] " ans + case "${ans:-Y}" in Y|y|"") INSTALL_SERVICE=yes ;; *) INSTALL_SERVICE=no ;; esac +fi + +if [ "$INSTALL_SERVICE" = "yes" ] && [ "$TARGET" != "macos" ] && [ -z "$INSTALL_MODE" ] && [ -t 0 ]; then + echo "" + echo " Install mode:" + echo " [S] system unit (needs sudo, binds udp/9 cleanly) — recommended" + echo " [u] --user unit (no sudo, but udp/9 won't bind without --setcap)" + read -rp " Choice [S/u] " mode + case "${mode:-S}" in u|U) INSTALL_MODE=user ;; *) INSTALL_MODE=system ;; esac +fi + +if [ "$INSTALL_SERVICE" = "yes" ]; then + install_args=("install") + [ "$INSTALL_MODE" = user ] && install_args+=("--user") + [ "$INSTALL_MODE" = user ] && command -v setcap >/dev/null 2>&1 && install_args+=("--setcap") + "$REPO_DIR/scripts/install-service.sh" "${install_args[@]}" || { + echo "" + echo " Service install failed. You can retry with: just service-install" + } +else + echo " Skipped. Install later with: just service-install" +fi + +if [ "$TARGET" = "wsl" ]; then + # UNC path to this repo from the Windows side — works on all recent WSL + # builds; \\wsl.localhost\ replaced \\wsl$\ in 2022+ but both still + # resolve. Use the newer form. + DISTRO="${WSL_DISTRO_NAME:-$(wslpath -w / 2>/dev/null | sed -n 's#^\\\\wsl[.$]localhost\\\([^\\]*\\\).*#\1#p')}" + DISTRO="${DISTRO:-Ubuntu}" + WIN_REPO="\\\\wsl.localhost\\${DISTRO}${REPO_DIR}" + echo "" + echo " ─── Windows-host step (do this from the Windows side) ──────────" + echo " You're in WSL2. Inbound Bolt udp/7373+9 still needs to be" + echo " forwarded from the Windows host. Open an ELEVATED PowerShell" + echo " and run:" + echo "" + echo " Set-ExecutionPolicy -Scope Process -Force Bypass" + echo " & '${WIN_REPO}\\scripts\\wsl-bolt-udp-forward.ps1' -Install -Firewall" + echo "" + echo " Or, equivalently, from this WSL shell (will pop a UAC prompt):" + echo "" + echo " powershell.exe -Command \"Start-Process powershell -Verb RunAs -ArgumentList '-NoExit','-ExecutionPolicy','Bypass','-File','${WIN_REPO}\\scripts\\wsl-bolt-udp-forward.ps1','-Install','-Firewall'\"" + echo "" + echo " After that completes, this WSL distro will be fully reachable" + echo " on udp/7373 from the LAN." +fi echo "" echo "Setup complete. Run 'just help-me' for common workflows." diff --git a/tests/install/README.md b/tests/install/README.md new file mode 100644 index 0000000..3baeaea --- /dev/null +++ b/tests/install/README.md @@ -0,0 +1,69 @@ +# Install-machinery tests + +Validates `setup.sh`, `setup.ps1`, `scripts/install-service.sh`, +`scripts/wsl-bolt-udp-forward.ps1`, and `assets/services/*` without +actually mutating the host. Safe to run anywhere. + +## Run locally + +```bash +tests/install/run.sh +``` + +Each check reports `PASS` / `FAIL` / `SKIP`. `SKIP` means the validator +isn't installed (shellcheck, pwsh, systemd-analyze, plutil/xmllint) — +not a failure. Install whichever are missing to widen coverage: + +| Validator | Linux | macOS | Windows | +|---|---|---|---| +| `bash -n`, `sed` | always | always | git-bash / WSL | +| `shellcheck` | `apt install shellcheck` | `brew install shellcheck` | git-bash + manual | +| `systemd-analyze` | `apt install systemd` | n/a (no systemd) | n/a | +| `xmllint` | `apt install libxml2-utils` | preinstalled | n/a | +| `plutil` | n/a | preinstalled | n/a | +| `pwsh` | snap/apt | `brew install --cask powershell` | preinstalled | +| `PSScriptAnalyzer` | `pwsh -c 'Install-Module PSScriptAnalyzer'` | same | same | + +## CI + +`.github/workflows/install-tests.yml` runs three jobs: + +- `lint-linux` — `tests/install/run.sh` with all Linux validators +- `lint-macos` — `tests/install/run.sh` on `macos-14` (real `plutil`) +- `lint-windows` — PowerShell AST parse + PSScriptAnalyzer + actually + compiles the embedded C# service host with the in-box `csc.exe` + (proves the runtime install path will work) + +Triggered on any change to the install machinery. + +## Round-trip tests (actually mutate the host) + +In addition to the lint suite, there are three platform-specific +drivers that do a real install → activate → stop → uninstall cycle +against the platform's service manager. They use stub `mix` / `deno` +binaries (`tests/install/stubs/`) so the spawned units survive long +enough to be Active without needing the full Elixir/Deno toolchain. + +| Driver | Platform | What it round-trips | +|---|---|---| +| `roundtrip-linux.sh` | Linux | `systemctl --user enable --now` round-trip (also kills the main PID and asserts `Restart=on-failure` respawns it) | +| `roundtrip-macos.sh` | macOS | `launchctl bootstrap gui/$UID` / `bootout` | +| `roundtrip-windows.ps1` | Windows | creates throwaway local user, installs Windows Service non-interactively via the new `-Credential` parameter on `wsl-bolt-udp-forward.ps1`, asserts SCM state, uninstalls, removes user | + +All three are idempotent and clean up after themselves on failure +(`trap EXIT` / `try { … } finally { … }`). They will mutate your host +for the duration of the test — safe locally if you're OK with a brief +service install. + +CI workflow `install-roundtrip.yml` runs them on: +- `ubuntu-latest` (with `loginctl enable-linger` to bring up user-systemd) +- `macos-14` (Apple Silicon) +- `windows-latest` + +## What's still NOT tested + +- The actual UDP forwarding (the Windows round-trip exercises install + + SCM state, not whether packets actually relay — that requires a + real WSL distro target). +- The Elixir/Deno app itself starting up correctly under systemd / + launchd — covered by `elixir-ci.yml`. diff --git a/tests/install/roundtrip-linux.sh b/tests/install/roundtrip-linux.sh new file mode 100755 index 0000000..ff0cba9 --- /dev/null +++ b/tests/install/roundtrip-linux.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-linux.sh — full install→activate→stop→uninstall +# cycle for the Linux --user systemd path. +# +# Strategy: +# * stub `mix` and `deno` with a script that sleeps forever, so the +# unit's ExecStart succeeds without needing the Elixir/Deno toolchain +# * inject the stub dir into the systemd --user instance's PATH via +# `systemctl --user import-environment` +# * install via scripts/install-service.sh install --user +# * assert both units reach `active` +# * uninstall, assert both are gone +# +# Safe-ish locally: it WILL mutate ~/.config/systemd/user/ and start +# burble.service in your user-systemd session for the duration of the +# test. The uninstall step undoes both even on failure (trap EXIT). + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +STUBS="$REPO_DIR/tests/install/stubs" +TMPLOG="$(mktemp -t burble-roundtrip-linux.XXXXXX.log)" +# shellcheck disable=SC2154 # ec is assigned inside the trap body +trap 'ec=$?; cleanup; exit $ec' EXIT + +PASS=0; FAIL=0 +pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +hdr() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +cleanup() { + echo + hdr "Cleanup" + "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1 || true + rm -f "$TMPLOG" +} + +# ─── Preflight ──────────────────────────────────────────────────────────── +hdr "Preflight" +command -v systemctl >/dev/null || { echo "systemctl required"; exit 2; } +[ -x "$STUBS/mix" ] && [ -x "$STUBS/deno" ] || { echo "missing stubs/"; exit 2; } + +# Need a user systemd session. `systemctl --user` requires either an +# interactive login (which CI lacks) or `loginctl enable-linger`. If we +# don't have a working bus, abort with a clear message. +CUR_USER="${USER:-$(id -un)}" +if ! systemctl --user is-system-running >/dev/null 2>&1 && \ + ! systemctl --user list-units >/dev/null 2>&1; then + echo "No working systemd --user instance for $CUR_USER." + echo "On CI: sudo loginctl enable-linger $CUR_USER && sleep 2" + echo "On a dev box, log in interactively or run 'systemctl --user start default.target'" + exit 2 +fi +pass "systemd --user instance reachable" + +# ─── Inject stub PATH into the user-systemd environment ─────────────────── +hdr "Stub PATH" +export PATH="$STUBS:$PATH" +systemctl --user import-environment PATH +pass "PATH=$STUBS:... imported into user-systemd env" + +# ─── Install ────────────────────────────────────────────────────────────── +hdr "Install (--user)" +if "$REPO_DIR/scripts/install-service.sh" install --user >>"$TMPLOG" 2>&1; then + pass "install --user exit 0" +else + fail "install --user exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +# Give systemd a moment to actually start the services. +sleep 2 + +# ─── Assert active ──────────────────────────────────────────────────────── +hdr "Assert active" +for unit in burble.service burble-ai-bridge.service; do + state=$(systemctl --user is-active "$unit" 2>/dev/null || echo unknown) + if [ "$state" = "active" ]; then + pass "$unit is active" + else + fail "$unit state=$state (expected active)" + systemctl --user status "$unit" --no-pager 2>&1 | sed 's/^/ /' | head -15 + fi +done + +# ─── Assert restart-on-failure works ────────────────────────────────────── +hdr "Restart-on-failure" +# Kill the burble.service main process; systemd should respawn it +# within RestartSec=5 because of Restart=on-failure. +mainpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null) +if [ -n "$mainpid" ] && [ "$mainpid" != "0" ]; then + kill -9 "$mainpid" 2>/dev/null || true + sleep 8 + newpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null) + if [ -n "$newpid" ] && [ "$newpid" != "0" ] && [ "$newpid" != "$mainpid" ]; then + pass "burble.service respawned after kill (pid $mainpid -> $newpid)" + else + fail "burble.service did not respawn (pid still $newpid)" + fi +else + fail "burble.service has no MainPID — cannot test restart" +fi + +# ─── Uninstall ──────────────────────────────────────────────────────────── +hdr "Uninstall" +if "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1; then + pass "uninstall exit 0" +else + fail "uninstall exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +sleep 1 + +# ─── Assert gone ────────────────────────────────────────────────────────── +hdr "Assert removed" +for unit in burble.service burble-ai-bridge.service; do + if [ -f "$HOME/.config/systemd/user/$unit" ]; then + fail "$unit file still present after uninstall" + else + pass "$unit file removed" + fi +done + +# ─── Summary ────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────" +printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m\n' "$PASS" "$FAIL" +echo "──────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/install/roundtrip-macos.sh b/tests/install/roundtrip-macos.sh new file mode 100755 index 0000000..a76f06e --- /dev/null +++ b/tests/install/roundtrip-macos.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-macos.sh — full install→activate→uninstall +# cycle for the macOS launchd LaunchAgent path. +# +# Strategy mirrors roundtrip-linux.sh: stub mix/deno on PATH so the +# LaunchAgent's program survives long enough to be Active before we +# tear it down. The stub PATH is baked into the rendered plist via +# pre-rendering — launchd doesn't import the user shell's environment. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +STUBS="$REPO_DIR/tests/install/stubs" +TMPLOG="$(mktemp -t burble-roundtrip-macos.XXXXXX.log)" +# shellcheck disable=SC2154 # ec is assigned inside the trap body +trap 'ec=$?; cleanup; exit $ec' EXIT + +PASS=0; FAIL=0 +pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +hdr() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +cleanup() { + echo + hdr "Cleanup" + "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1 || true + rm -f "$TMPLOG" + # Undo the PATH-injection hack if we used it. + git -C "$REPO_DIR" checkout -- assets/services/*.plist 2>/dev/null || true +} + +# ─── Preflight ──────────────────────────────────────────────────────────── +hdr "Preflight" +[ "$(uname -s)" = Darwin ] || { echo "macOS only"; exit 2; } +command -v launchctl >/dev/null || { echo "launchctl required"; exit 2; } +[ -x "$STUBS/mix" ] && [ -x "$STUBS/deno" ] || { echo "missing stubs/"; exit 2; } +pass "launchctl present, stubs ready" + +# ─── Inject stub PATH into the plists ───────────────────────────────────── +# launchd ignores the user shell's PATH, so add EnvironmentVariables.PATH +# directly into the plist source. The render step in install-service.sh +# will copy it verbatim into ~/Library/LaunchAgents/. +hdr "Patch plists with stub PATH" +patch_plist() { + local plist="$1" + if grep -q 'PATH' "$plist"; then return 0; fi + # Insert PATH=… into the EnvironmentVariables dict. If the dict + # doesn't exist, create one. + if grep -q 'EnvironmentVariables' "$plist"; then + # Append PATH… inside the existing dict. + sed -i.bak "s|EnvironmentVariables\\ + |EnvironmentVariables\\ + \\ + PATH\\ + $STUBS:/usr/bin:/bin|" "$plist" + else + # Insert before at the top level. + sed -i.bak "/<\/dict>/i\\ + EnvironmentVariables\\ + \\ + PATH\\ + $STUBS:/usr/bin:/bin\\ + " "$plist" + fi +} +for p in "$REPO_DIR/assets/services/com.hyperpolymath.burble.plist" \ + "$REPO_DIR/assets/services/com.hyperpolymath.burble.ai-bridge.plist"; do + patch_plist "$p" + plutil -lint "$p" >/dev/null 2>&1 && pass "$(basename "$p") still valid after patch" \ + || fail "$(basename "$p") invalid after patch" +done + +# ─── Install ────────────────────────────────────────────────────────────── +hdr "Install" +if "$REPO_DIR/scripts/install-service.sh" install >>"$TMPLOG" 2>&1; then + pass "install exit 0" +else + fail "install exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +sleep 2 + +# ─── Assert loaded ──────────────────────────────────────────────────────── +hdr "Assert loaded" +for label in com.hyperpolymath.burble com.hyperpolymath.burble.ai-bridge; do + if launchctl print "gui/$UID/$label" >/dev/null 2>&1; then + pass "$label loaded in gui/$UID" + else + fail "$label not loaded" + launchctl print "gui/$UID/$label" 2>&1 | head -10 | sed 's/^/ /' + fi +done + +# ─── Uninstall ──────────────────────────────────────────────────────────── +hdr "Uninstall" +if "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1; then + pass "uninstall exit 0" +else + fail "uninstall exit $?" +fi + +sleep 1 + +# ─── Assert gone ────────────────────────────────────────────────────────── +hdr "Assert removed" +for label in com.hyperpolymath.burble com.hyperpolymath.burble.ai-bridge; do + if launchctl print "gui/$UID/$label" >/dev/null 2>&1; then + fail "$label still loaded after uninstall" + else + pass "$label gone" + fi + plist="$HOME/Library/LaunchAgents/$label.plist" + if [ -f "$plist" ]; then + fail "$plist still on disk" + else + pass "$plist removed from disk" + fi +done + +# ─── Summary ────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────" +printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m\n' "$PASS" "$FAIL" +echo "──────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/install/roundtrip-windows.ps1 b/tests/install/roundtrip-windows.ps1 new file mode 100644 index 0000000..fac3437 --- /dev/null +++ b/tests/install/roundtrip-windows.ps1 @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-windows.ps1 — full install→start→stop→ +# uninstall cycle for the Windows Service path in +# scripts/wsl-bolt-udp-forward.ps1 -Install. +# +# Strategy: +# * create a throwaway local user `burble-ci-test` with a random +# password and add it to the Users group +# * pass a pre-built PSCredential to -Install (skips Get-Credential +# interactive prompt — that's why we added the -Credential param) +# * assert the service installs, Get-Service shows Stopped/Running, +# Start-Service works (or fails benignly because wsl.exe can't +# resolve a distro in CI — that's fine, we only care about the +# SCM install/uninstall round-trip) +# * Uninstall, assert Get-Service no longer finds the service +# * Always remove the throwaway user in a `finally` block +# +# Run from an elevated PowerShell. Mostly intended for CI +# (windows-latest); also runnable on a dev box if you don't mind the +# transient local user. + +[CmdletBinding()] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseBOMForUnicodeEncodedFile", "")] +param() + +$ErrorActionPreference = 'Stop' + +$RepoDir = (Resolve-Path "$PSScriptRoot\..\..").Path +$Forwarder = Join-Path $RepoDir 'scripts\wsl-bolt-udp-forward.ps1' +$SvcName = 'BurbleBoltUdpForward' +$TestUser = 'burble-ci-test' + +$script:Pass = 0; $script:Fail = 0 +function Pass($m) { Write-Host (" PASS {0}" -f $m) -ForegroundColor Green; $script:Pass++ } +function Fail($m) { Write-Host (" FAIL {0}" -f $m) -ForegroundColor Red; $script:Fail++ } +function Hdr($m) { Write-Host ""; Write-Host ("── {0} ──" -f $m) -ForegroundColor Cyan } + +# ─── Preflight ──────────────────────────────────────────────────────────── +Hdr "Preflight" +$elev = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) +if (-not $elev) { Write-Error "Must run elevated."; exit 2 } +Pass "elevated" + +if (-not (Test-Path $Forwarder)) { + Write-Error "Forwarder script not found at $Forwarder"; exit 2 +} +Pass "forwarder script found" + +# ─── Create throwaway local user ────────────────────────────────────────── +Hdr "Create throwaway local user" +$Plain = -join ((33..126) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) +# Strip characters that the SCM's credential parser dislikes +$Plain = ($Plain -replace '[\\"`]', 'x') +$SecurePw = ConvertTo-SecureString $Plain -AsPlainText -Force + +try { + Remove-LocalUser -Name $TestUser -ErrorAction SilentlyContinue + New-LocalUser -Name $TestUser -Password $SecurePw ` + -AccountNeverExpires -PasswordNeverExpires ` + -Description 'Burble test user — safe to delete' | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $TestUser -ErrorAction SilentlyContinue + Pass "created local user $TestUser" + + $Cred = New-Object System.Management.Automation.PSCredential( + ".\$TestUser", $SecurePw) + + # ─── Install via -Credential (non-interactive) ─────────────────────── + Hdr "Install service" + try { + & pwsh.exe -NoProfile -ExecutionPolicy Bypass -File $Forwarder ` + -Install -Credential $Cred -Distro 'Ubuntu' -Ports 7373,9 + if ($LASTEXITCODE -eq 0) { Pass "install exit 0" } + else { Fail "install exit $LASTEXITCODE" } + } catch { + Fail "install threw: $($_.Exception.Message)" + } + + Start-Sleep -Seconds 2 + + # ─── Assert service registered ─────────────────────────────────────── + Hdr "Assert service registered" + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if ($svc) { + Pass "Get-Service finds $SvcName (status=$($svc.Status), startup=$($svc.StartType))" + } else { + Fail "Get-Service did not find $SvcName" + } + + # The relay child will likely crash in CI because wsl.exe can't + # resolve a real distro IP. That's expected — we only care that the + # SCM successfully launched the service host. We tolerate either + # Running (briefly) or Stopped (after the child died and the + # service host exited too). + if ($svc -and $svc.Status -in @('Running','Stopped','StartPending')) { + Pass "service reached a known state ($($svc.Status))" + } + + # ─── Try to stop cleanly ───────────────────────────────────────────── + Hdr "Stop service" + Stop-Service -Name $SvcName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if ($svc.Status -eq 'Stopped') { Pass "service stopped" } + else { Fail "service status=$($svc.Status) after Stop" } + + # ─── Uninstall ─────────────────────────────────────────────────────── + Hdr "Uninstall" + & pwsh.exe -NoProfile -ExecutionPolicy Bypass -File $Forwarder -Uninstall + if ($LASTEXITCODE -eq 0) { Pass "uninstall exit 0" } + else { Fail "uninstall exit $LASTEXITCODE" } + + Start-Sleep -Seconds 1 + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if (-not $svc) { Pass "service removed (Get-Service finds nothing)" } + else { Fail "service still present: $($svc.Status)" } + +} finally { + # ─── Cleanup — always remove the throwaway user ────────────────────── + Hdr "Cleanup" + try { + & sc.exe delete $SvcName | Out-Null + } catch {} + try { + Remove-LocalUser -Name $TestUser -ErrorAction SilentlyContinue + Pass "removed throwaway user $TestUser" + } catch { + Write-Host " WARN failed to remove ${TestUser}: $($_.Exception.Message)" -ForegroundColor Yellow + } +} + +# ─── Summary ────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "──────────────────────────────────────────" +Write-Host ("Results: {0} pass, {1} fail" -f $script:Pass, $script:Fail) -ForegroundColor $(if ($script:Fail -eq 0) { 'Green' } else { 'Red' }) +Write-Host "──────────────────────────────────────────" +if ($script:Fail -ne 0) { exit 1 } diff --git a/tests/install/run.sh b/tests/install/run.sh new file mode 100755 index 0000000..97e1c36 --- /dev/null +++ b/tests/install/run.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/run.sh — exercise the cross-platform install machinery +# without actually mutating the host. Safe to run anywhere; skips checks +# whose tooling isn't installed (shellcheck, xmllint, systemd-analyze, +# pwsh) and reports them as SKIP instead of FAIL. +# +# Used by .github/workflows/install-tests.yml — keep the local and CI +# code paths the same so "works on my machine" actually means something. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +TMP="$(mktemp -d -t burble-install-tests.XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0; SKIP=0 +pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +skip() { printf ' \033[0;33mSKIP\033[0m %s (%s)\n' "$1" "$2"; SKIP=$((SKIP+1)); } +section() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +# ─── 1. Shell syntax + shellcheck ───────────────────────────────────────── +section "Shell scripts" +SHELL_FILES=(setup.sh scripts/install-service.sh + tests/install/run.sh tests/install/roundtrip-linux.sh + tests/install/roundtrip-macos.sh) +for f in "${SHELL_FILES[@]}"; do + if bash -n "$REPO_DIR/$f" 2>/dev/null; then pass "bash -n $f" + else fail "bash -n $f"; fi +done + +if command -v shellcheck >/dev/null 2>&1; then + for f in "${SHELL_FILES[@]}"; do + # SC1091: don't try to follow optionally-sourced files + # SC2086: word splitting is intentional in arg arrays + if shellcheck -S warning -e SC1091 -e SC2086 "$REPO_DIR/$f" >"$TMP/sc.out" 2>&1; then + pass "shellcheck $f" + else + fail "shellcheck $f" + sed 's/^/ /' "$TMP/sc.out" + fi + done +else + skip "shellcheck" "binary not installed" +fi + +# ─── 2. systemd unit rendering + validation ─────────────────────────────── +section "Linux systemd units" + +# Replicate install-service.sh's render_unit logic — we test the +# rendered output, not just the templates, because that's what systemd +# actually loads. +render_unit() { + local mode="$1" src="$2" dst="$3" + if [ "$mode" = user ]; then + sed -e "s|@REPO_DIR@|$REPO_DIR|g" -e "s|@USER@|testuser|g" \ + -e "/^User=/d" -e "/^Group=/d" \ + -e "s|^WantedBy=multi-user.target|WantedBy=default.target|" \ + -e "/^AmbientCapabilities=/d" -e "/^CapabilityBoundingSet=/d" \ + "$src" > "$dst" + else + sed -e "s|@REPO_DIR@|$REPO_DIR|g" -e "s|@USER@|testuser|g" \ + "$src" > "$dst" + fi +} + +for unit in burble.service burble-ai-bridge.service; do + src="$REPO_DIR/assets/services/$unit" + [ -f "$src" ] || { fail "missing template $unit"; continue; } + + for mode in system user; do + out="$TMP/${mode}-${unit}" + render_unit "$mode" "$src" "$out" + + # Invariant 1: no @TOKEN@ should remain after rendering + if grep -q '@[A-Z_]*@' "$out"; then + fail "$mode/$unit: unsubstituted tokens remain" + grep '@[A-Z_]*@' "$out" | sed 's/^/ /' + else + pass "$mode/$unit: no unsubstituted tokens" + fi + + # Invariant 2: required sections present + if grep -q '^\[Unit\]' "$out" && \ + grep -q '^\[Service\]' "$out" && \ + grep -q '^\[Install\]' "$out"; then + pass "$mode/$unit: has [Unit] [Service] [Install]" + else + fail "$mode/$unit: missing required section" + fi + + # Mode-specific invariants + if [ "$mode" = user ]; then + if grep -qE '^(User|Group|AmbientCapabilities|CapabilityBoundingSet)=' "$out"; then + fail "$mode/$unit: contains directives invalid in --user mode" + grep -E '^(User|Group|AmbientCapabilities|CapabilityBoundingSet)=' "$out" | sed 's/^/ /' + else + pass "$mode/$unit: stripped system-only directives" + fi + if grep -q '^WantedBy=default.target' "$out"; then + pass "$mode/$unit: WantedBy rewritten to default.target" + else + fail "$mode/$unit: WantedBy not rewritten" + fi + else + if [ "$unit" = burble.service ]; then + grep -q '^AmbientCapabilities=CAP_NET_BIND_SERVICE' "$out" \ + && pass "$mode/$unit: has AmbientCapabilities=CAP_NET_BIND_SERVICE" \ + || fail "$mode/$unit: missing AmbientCapabilities (udp/9 won't bind)" + fi + grep -q '^User=testuser' "$out" \ + && pass "$mode/$unit: User= substituted" \ + || fail "$mode/$unit: User= not substituted" + fi + done +done + +# systemd-analyze verify catches a lot — typos in directive names, +# invalid values, missing sections, dependency cycles. +if command -v systemd-analyze >/dev/null 2>&1; then + for out in "$TMP"/system-*.service; do + if systemd-analyze verify --no-pager "$out" >"$TMP/sa.out" 2>&1; then + pass "systemd-analyze verify $(basename "$out")" + else + # Some failures are expected in unprivileged CI (e.g., User= + # doesn't exist) — only fail on structural errors. + if grep -qE '(unknown setting|syntax error|requires.*not found|fail to parse)' "$TMP/sa.out"; then + fail "systemd-analyze verify $(basename "$out") (structural)" + sed 's/^/ /' "$TMP/sa.out" + else + pass "systemd-analyze verify $(basename "$out") (advisory warnings only)" + fi + fi + done +else + skip "systemd-analyze verify" "systemd not installed" +fi + +# ─── 3. macOS launchd plist XML validity ────────────────────────────────── +section "macOS launchd plists" + +PLISTS=(com.hyperpolymath.burble.plist com.hyperpolymath.burble.ai-bridge.plist) +for p in "${PLISTS[@]}"; do + src="$REPO_DIR/assets/services/$p" + [ -f "$src" ] || { fail "missing $p"; continue; } + # Render with @REPO_DIR@ substituted (the only token in plists) + out="$TMP/$p" + sed "s|@REPO_DIR@|$REPO_DIR|g" "$src" > "$out" + + if grep -q '@[A-Z_]*@' "$out"; then + fail "$p: unsubstituted tokens" + else + pass "$p: no unsubstituted tokens" + fi + + if command -v plutil >/dev/null 2>&1; then + if plutil -lint "$out" >"$TMP/pl.out" 2>&1; then + pass "plutil -lint $p" + else + fail "plutil -lint $p"; sed 's/^/ /' "$TMP/pl.out" + fi + elif command -v xmllint >/dev/null 2>&1; then + # xmllint is a weaker check (XML well-formedness, not plist + # schema) but it catches typos that break the parser. + if xmllint --noout "$out" 2>"$TMP/xl.out"; then + pass "xmllint --noout $p" + else + fail "xmllint --noout $p"; sed 's/^/ /' "$TMP/xl.out" + fi + else + skip "plist XML validity for $p" "no plutil/xmllint" + fi +done + +# ─── 4. PowerShell scripts (syntax + analyzer) ──────────────────────────── +section "PowerShell scripts" +PS_FILES=(setup.ps1 scripts/wsl-bolt-udp-forward.ps1 + tests/install/roundtrip-windows.ps1) +PWSH="" +command -v pwsh >/dev/null 2>&1 && PWSH=pwsh +[ -z "$PWSH" ] && command -v powershell >/dev/null 2>&1 && PWSH=powershell + +if [ -n "$PWSH" ]; then + for f in "${PS_FILES[@]}"; do + # Parse-only check via the AST parser — no execution. + if "$PWSH" -NoProfile -Command " + \$errors = \$null + [System.Management.Automation.Language.Parser]::ParseFile( + '$REPO_DIR/$f', [ref]\$null, [ref]\$errors) | Out-Null + if (\$errors -and \$errors.Count -gt 0) { + \$errors | ForEach-Object { Write-Host \" \$_\" } + exit 1 + }" >"$TMP/ps.out" 2>&1; then + pass "powershell parse $f" + else + fail "powershell parse $f"; sed 's/^/ /' "$TMP/ps.out" + fi + done + + # PSScriptAnalyzer if installed + if "$PWSH" -NoProfile -Command "Get-Module -ListAvailable PSScriptAnalyzer" 2>/dev/null | grep -q PSScriptAnalyzer; then + for f in "${PS_FILES[@]}"; do + if "$PWSH" -NoProfile -Command " + \$r = Invoke-ScriptAnalyzer -Path '$REPO_DIR/$f' -Severity Warning,Error \` + -ExcludeRule PSAvoidUsingWriteHost, \` + PSAvoidUsingPlainTextForPassword, \` + PSAvoidUsingConvertToSecureStringWithPlainText + if (\$r) { \$r | Format-Table -AutoSize | Out-String | Write-Host; exit 1 } + exit 0" >"$TMP/psa.out" 2>&1; then + pass "PSScriptAnalyzer $f" + else + fail "PSScriptAnalyzer $f"; sed 's/^/ /' "$TMP/psa.out" + fi + done + else + skip "PSScriptAnalyzer" "module not installed" + fi +else + skip "PowerShell parse + PSScriptAnalyzer" "no pwsh/powershell on PATH" +fi + +# ─── 5. setup.sh OS detection + dispatch ────────────────────────────────── +section "setup.sh OS dispatch" + +# Run setup.sh with non-interactive opt-out and capture stdout. Verify +# it reports the expected platform string for our actual OS. +case "$(uname -s)" in + Linux*) if grep -qi microsoft /proc/version 2>/dev/null; then EXPECT=wsl; else EXPECT=linux; fi ;; + Darwin*) EXPECT=macos ;; + *) EXPECT="unknown" ;; +esac + +if [ "$EXPECT" != "unknown" ]; then + out=$(BURBLE_INSTALL_SERVICE=no bash "$REPO_DIR/setup.sh" 2>&1 || true) + if echo "$out" | grep -qi "Background-service install ($EXPECT)"; then + pass "setup.sh detected target=$EXPECT" + else + fail "setup.sh did not detect target=$EXPECT" + echo "$out" | tail -10 | sed 's/^/ /' + fi +else + skip "setup.sh OS dispatch" "unknown host OS" +fi + + +# ─── Summary ────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────" +printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m, \033[0;33m%d skip\033[0m\n' "$PASS" "$FAIL" "$SKIP" +echo "──────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/install/stubs/deno b/tests/install/stubs/deno new file mode 100755 index 0000000..d582c38 --- /dev/null +++ b/tests/install/stubs/deno @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Test stub for `deno` — see stubs/mix for rationale. +echo "[stub deno] $* (sleeping forever to keep the unit Active)" +exec sleep infinity diff --git a/tests/install/stubs/mix b/tests/install/stubs/mix new file mode 100755 index 0000000..8cf1175 --- /dev/null +++ b/tests/install/stubs/mix @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Test stub for `mix` used by tests/install/roundtrip-*.sh. The install +# round-trip tests don't care whether Phoenix actually starts — they +# care whether the unit installs, activates, deactivates, and uninstalls +# cleanly. Sleeping forever satisfies systemd's "service is running" +# check without needing the full Elixir/Erlang toolchain. +echo "[stub mix] $* (sleeping forever to keep the unit Active)" +exec sleep infinity