diff --git a/README.md b/README.md index 73c52e3..f988f0b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ git clone https://github.com/splunk/token-meter.git The installer stages a stable per-user runtime, starts the local server and native companion, and configures automatic startup. +For a browser-dashboard-only installation without the macOS menu-bar or Linux +tray companion, use `./token-meter/scripts/install --backend-only`. This mode +does not require the Swift toolchain or Linux GTK/AppIndicator packages, and it +is preserved by automatic updates. + ### Windows > **Beta:** The Windows extension is still in beta. @@ -40,6 +45,11 @@ The bootstrap uses WinGet from Microsoft App Installer to install missing Git an It then stages the beta extension without administrator access. From an existing checkout, rerun `.\scripts\install-windows.cmd`. +For a browser-dashboard-only installation without the Windows notification-area companion, +add `-BackendOnly` to the downloaded bootstrap invocation +(`& $p -BackendOnly`) or run `.\scripts\install-windows.cmd -BackendOnly` from +an existing checkout. Automatic updates preserve this mode. + ### Open Token Meter Open [http://127.0.0.1:8722](http://127.0.0.1:8722), start a normal agent run, diff --git a/scripts/bootstrap-windows.ps1 b/scripts/bootstrap-windows.ps1 index 220df61..5db7941 100644 --- a/scripts/bootstrap-windows.ps1 +++ b/scripts/bootstrap-windows.ps1 @@ -2,7 +2,8 @@ param( [string]$SourceRoot = "", [string]$InstallRoot = "", - [switch]$NoOpenDashboard + [switch]$NoOpenDashboard, + [switch]$BackendOnly ) $ErrorActionPreference = "Stop" @@ -231,7 +232,11 @@ Write-Host "Managed source: $SourceRoot" $PreviousSourceOverride = $env:TOKEN_METER_SOURCE_ROOT try { $env:TOKEN_METER_SOURCE_ROOT = $SourceRoot - & $Wrapper -InstallRoot $InstallRoot + $InstallerArguments = @("-InstallRoot", $InstallRoot) + if ($BackendOnly) { + $InstallerArguments += "-BackendOnly" + } + & $Wrapper @InstallerArguments if ($LASTEXITCODE -ne 0) { Fail "the Token Meter runtime installer failed." } diff --git a/scripts/install b/scripts/install index 3b5826d..c2bf692 100755 --- a/scripts/install +++ b/scripts/install @@ -2,6 +2,35 @@ set -euo pipefail SOURCE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_ONLY=0 + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + PLATFORM="$(uname -s)" if [[ "$PLATFORM" == "Linux" ]]; then exec "$SOURCE_ROOT/scripts/install-linux" "$@" @@ -26,7 +55,10 @@ fail() { exit 1 } -required_commands=(git python3 curl swiftc launchctl ditto) +required_commands=(git python3 curl launchctl ditto) +if (( ! BACKEND_ONLY )); then + required_commands+=(swiftc) +fi for command_name in "${required_commands[@]}"; do if ! command -v "$command_name" >/dev/null 2>&1; then if [[ "$command_name" == "swiftc" ]]; then @@ -172,7 +204,11 @@ else echo "Local Git delivery history will remain partial until a readable repository is observed." fi -"$INSTALL_ROOT/scripts/install-launch-agent" server-only +if (( BACKEND_ONLY )); then + "$INSTALL_ROOT/scripts/install-launch-agent" backend-only +else + "$INSTALL_ROOT/scripts/install-launch-agent" server-only +fi health_json="" readiness_deadline=$((SECONDS + READINESS_TIMEOUT_SECONDS)) @@ -212,22 +248,28 @@ if not matches: f"(page_path={page or 'unknown'}).") ' "$INSTALL_ROOT" -"$INSTALL_ROOT/scripts/install-launch-agent" menubar-only +if (( BACKEND_ONLY )); then + printf '%s\n' "backend-only" > "$INSTALL_ROOT/INSTALL_MODE" +else + "$INSTALL_ROOT/scripts/install-launch-agent" menubar-only -if ! menubar_launch_state="$(launchctl print "gui/$UID/$MENUBAR_LABEL" 2>&1)"; then - fail "the menu bar LaunchAgent was not registered." -fi -[[ "$menubar_launch_state" == *"$INSTALL_ROOT/scripts/run-menubar"* ]] \ - || fail "a different Token Meter installation owns the menu bar LaunchAgent." + if ! menubar_launch_state="$(launchctl print "gui/$UID/$MENUBAR_LABEL" 2>&1)"; then + fail "the menu bar LaunchAgent was not registered." + fi + [[ "$menubar_launch_state" == *"$INSTALL_ROOT/scripts/run-menubar"* ]] \ + || fail "a different Token Meter installation owns the menu bar LaunchAgent." -curl -fsS --max-time 5 "$MENUBAR_URL" \ - | python3 -c 'import json, sys; json.load(sys.stdin)' \ - || fail "the menu bar endpoint did not return valid JSON." + curl -fsS --max-time 5 "$MENUBAR_URL" \ + | python3 -c 'import json, sys; json.load(sys.stdin)' \ + || fail "the menu bar endpoint did not return valid JSON." + printf '%s\n' "full" > "$INSTALL_ROOT/INSTALL_MODE" +fi echo echo "Token Meter installation complete." echo "Dashboard: $DASHBOARD_URL" echo "Starts automatically after you log in." +(( BACKEND_ONLY )) && echo "Backend-only installation: native companion skipped." echo "Runtime: $INSTALL_ROOT" [[ -n "$commit" ]] && echo "Installed commit: $commit" echo "Uninstall: $INSTALL_ROOT/scripts/uninstall-launch-agent" diff --git a/scripts/install-launch-agent b/scripts/install-launch-agent index a106938..fe8c7ba 100755 --- a/scripts/install-launch-agent +++ b/scripts/install-launch-agent @@ -13,9 +13,9 @@ SERVER_PROGRAM="$ROOT/meter.py" MENUBAR_PROGRAM="$ROOT/scripts/run-menubar" case "$MODE" in - all|server-only|menubar-only) ;; + all|server-only|menubar-only|backend-only) ;; *) - echo "Usage: $0 [all|server-only|menubar-only]" >&2 + echo "Usage: $0 [all|server-only|menubar-only|backend-only]" >&2 exit 2 ;; esac @@ -28,7 +28,7 @@ if [[ ! -f "$SERVER_PROGRAM" ]]; then echo "Missing server: $SERVER_PROGRAM" >&2 exit 1 fi -if [[ ! -x "$MENUBAR_PROGRAM" ]]; then +if [[ "$MODE" != "backend-only" && ! -x "$MENUBAR_PROGRAM" ]]; then echo "Missing executable menu bar runner: $MENUBAR_PROGRAM" >&2 echo "Run: chmod +x $MENUBAR_PROGRAM" >&2 exit 1 @@ -76,7 +76,8 @@ then exit 1 fi -if ! cat > "$MENUBAR_PLIST" < "$MENUBAR_PLIST" < @@ -106,11 +107,12 @@ if ! cat > "$MENUBAR_PLIST" < PLIST -then - echo "Token Meter could not write $MENUBAR_PLIST." >&2 - echo "If a coding agent is running this installer, approve its request for that" >&2 - echo "exact user LaunchAgent file, then retry." >&2 - exit 1 + then + echo "Token Meter could not write $MENUBAR_PLIST." >&2 + echo "If a coding agent is running this installer, approve its request for that" >&2 + echo "exact user LaunchAgent file, then retry." >&2 + exit 1 + fi fi bootstrap_service() { @@ -163,6 +165,12 @@ start_menubar() { } case "$MODE" in + backend-only) + launchctl bootout "gui/$UID/$MENUBAR_LABEL" >/dev/null 2>&1 || true + rm -f "$MENUBAR_PLIST" + launchctl bootout "gui/$UID/$SERVER_LABEL" >/dev/null 2>&1 || true + start_server + ;; server-only) launchctl bootout "gui/$UID/$MENUBAR_LABEL" >/dev/null 2>&1 || true launchctl bootout "gui/$UID/$SERVER_LABEL" >/dev/null 2>&1 || true diff --git a/scripts/install-linux b/scripts/install-linux index 7e3a618..9321779 100755 --- a/scripts/install-linux +++ b/scripts/install-linux @@ -2,6 +2,35 @@ set -euo pipefail SOURCE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_ONLY=0 + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + PLATFORM="$(uname -s)" if [[ "$PLATFORM" != "Linux" ]]; then echo "Token Meter Linux installation failed: this installer requires Linux." >&2 @@ -34,7 +63,7 @@ RUNTIME_MANIFEST="$SOURCE_ROOT/runtime-manifest.txt" PYTHONPATH="$SOURCE_ROOT" python3 -m token_meter.packaging manifest "$RUNTIME_MANIFEST" \ || fail "the runtime packaging manifest is invalid or incomplete." -if ! "$SOURCE_ROOT/scripts/run-menubar" --check; then +if (( ! BACKEND_ONLY )) && ! "$SOURCE_ROOT/scripts/run-menubar" --check; then fail "the Linux tray dependencies are missing. Install the GTK/AppIndicator packages shown above, then retry." fi if ! systemctl --user show-environment >/dev/null 2>&1; then @@ -152,7 +181,11 @@ else echo "Local Git delivery history will remain partial until a readable repository is observed." fi -"$INSTALL_ROOT/scripts/install-systemd-user" server-only +if (( BACKEND_ONLY )); then + "$INSTALL_ROOT/scripts/install-systemd-user" backend-only +else + "$INSTALL_ROOT/scripts/install-systemd-user" server-only +fi health_json="" readiness_deadline=$((SECONDS + READINESS_TIMEOUT_SECONDS)) @@ -190,22 +223,28 @@ if not matches: f"(page_path={page or 'unknown'}).") ' "$INSTALL_ROOT" -"$INSTALL_ROOT/scripts/install-systemd-user" menubar-only - -systemctl --user is-active --quiet token-meter-tray.service \ - || { journalctl --user -u token-meter-tray.service -n 30 --no-pager >&2 || true; fail "token-meter-tray.service is not running."; } -tray_fragment="$(systemctl --user show token-meter-tray.service -p FragmentPath --value)" -[[ "$tray_fragment" == "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/token-meter-tray.service" ]] \ - || fail "a different unit owns token-meter-tray.service." - -curl -fsS --max-time 5 "$MENUBAR_URL" \ - | python3 -c 'import json, sys; json.load(sys.stdin)' \ - || fail "the menu bar endpoint did not return valid JSON." +if (( BACKEND_ONLY )); then + printf '%s\n' "backend-only" > "$INSTALL_ROOT/INSTALL_MODE" +else + "$INSTALL_ROOT/scripts/install-systemd-user" menubar-only + + systemctl --user is-active --quiet token-meter-tray.service \ + || { journalctl --user -u token-meter-tray.service -n 30 --no-pager >&2 || true; fail "token-meter-tray.service is not running."; } + tray_fragment="$(systemctl --user show token-meter-tray.service -p FragmentPath --value)" + [[ "$tray_fragment" == "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/token-meter-tray.service" ]] \ + || fail "a different unit owns token-meter-tray.service." + + curl -fsS --max-time 5 "$MENUBAR_URL" \ + | python3 -c 'import json, sys; json.load(sys.stdin)' \ + || fail "the menu bar endpoint did not return valid JSON." + printf '%s\n' "full" > "$INSTALL_ROOT/INSTALL_MODE" +fi echo echo "Token Meter installation complete." echo "Dashboard: $DASHBOARD_URL" echo "Starts automatically after you log in." +(( BACKEND_ONLY )) && echo "Backend-only installation: native companion skipped." echo "Runtime: $INSTALL_ROOT" [[ -n "$commit" ]] && echo "Installed commit: $commit" echo "Uninstall: $INSTALL_ROOT/scripts/uninstall-systemd-user" diff --git a/scripts/install-systemd-user b/scripts/install-systemd-user index 69b1c87..23e7625 100755 --- a/scripts/install-systemd-user +++ b/scripts/install-systemd-user @@ -10,9 +10,9 @@ PYTHON_BIN="$(command -v python3 || true)" UNIT_ROOT="${ROOT// /\x20}" case "$MODE" in - all|server-only|menubar-only) ;; + all|server-only|menubar-only|backend-only) ;; *) - echo "Usage: $0 [all|server-only|menubar-only]" >&2 + echo "Usage: $0 [all|server-only|menubar-only|backend-only]" >&2 exit 2 ;; esac @@ -38,7 +38,8 @@ RestartSec=5 WantedBy=default.target UNIT -tee "$TRAY_UNIT" >/dev/null </dev/null </dev/null 2>&1 || true + rm -f "$TRAY_UNIT" + systemctl --user disable --now token-meter-server.service >/dev/null 2>&1 || true + start_server + ;; server-only) systemctl --user disable --now token-meter-tray.service >/dev/null 2>&1 || true systemctl --user disable --now token-meter-server.service >/dev/null 2>&1 || true diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index f23d058..881ae13 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [string]$InstallRoot = "", - [int]$ReadinessTimeoutSeconds = 0 + [int]$ReadinessTimeoutSeconds = 0, + [switch]$BackendOnly ) $ErrorActionPreference = "Stop" @@ -465,6 +466,8 @@ try { Write-Utf8File (Join-Path $StagingRoot "SOURCE_CHECKOUT") ($UpdateSourceRoot + [Environment]::NewLine) Write-Utf8File (Join-Path $StagingRoot "PYTHON_EXECUTABLE") ($PythonExe + [Environment]::NewLine) Write-Utf8File (Join-Path $StagingRoot "PYTHON_WINDOWED_EXECUTABLE") ($PythonwExe + [Environment]::NewLine) +$InstallMode = if ($BackendOnly) { "backend-only" } else { "full" } +Write-Utf8File (Join-Path $StagingRoot "INSTALL_MODE") ($InstallMode + [Environment]::NewLine) $Commit = (& $Git.Source -C $SourceRoot rev-parse --short HEAD 2>$null | Out-String).Trim() if ($Commit) { @@ -532,19 +535,21 @@ try { if ($ActualPage -ne $ExpectedPage) { Fail "a different Token Meter installation owns port 8722." } - $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/menubar" -TimeoutSec 5 - $TrayStatusPath = Join-Path $InstallRoot "tray.status.json" - if (-not (Test-Path -LiteralPath $TrayStatusPath -PathType Leaf)) { - Fail "the Windows tray widget did not publish its status." - } - $TrayStatus = Get-Content -LiteralPath $TrayStatusPath -Raw | ConvertFrom-Json - if (-not $TrayStatus.ready -or -not $TrayStatus.connected) { - Fail "the Windows tray widget did not become ready." - } - $TrayProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $([int]$TrayStatus.pid)" -ErrorAction SilentlyContinue - $ExpectedTray = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot "scripts\run-tray.ps1")) - if (-not $TrayProcess -or [string]$TrayProcess.CommandLine -notlike "*$ExpectedTray*") { - Fail "the Windows tray widget process could not be verified." + if (-not $BackendOnly) { + $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/menubar" -TimeoutSec 5 + $TrayStatusPath = Join-Path $InstallRoot "tray.status.json" + if (-not (Test-Path -LiteralPath $TrayStatusPath -PathType Leaf)) { + Fail "the Windows tray widget did not publish its status." + } + $TrayStatus = Get-Content -LiteralPath $TrayStatusPath -Raw | ConvertFrom-Json + if (-not $TrayStatus.ready -or -not $TrayStatus.connected) { + Fail "the Windows tray widget did not become ready." + } + $TrayProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $([int]$TrayStatus.pid)" -ErrorAction SilentlyContinue + $ExpectedTray = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot "scripts\run-tray.ps1")) + if (-not $TrayProcess -or [string]$TrayProcess.CommandLine -notlike "*$ExpectedTray*") { + Fail "the Windows tray widget process could not be verified." + } } $SavedRunValue = (Get-ItemProperty -LiteralPath $RunKey -Name $RunName).$RunName if ($SavedRunValue -ne $StartupCommand) { @@ -579,7 +584,11 @@ $PythonArchitecture = (& $PythonExe -c "import platform; print(platform.machine( Write-Host "" Write-Host "Token Meter installation complete." Write-Host "Dashboard: http://127.0.0.1:8722" -Write-Host "Tray widget: running" +if ($BackendOnly) { + Write-Host "Backend-only installation: notification-area companion skipped." +} else { + Write-Host "Tray widget: running" +} Write-Host "Starts automatically after you log in." Write-Host "Runtime: $InstallRoot" Write-Host "Python: $PythonExe ($PythonArchitecture)" diff --git a/scripts/start-token-meter.ps1 b/scripts/start-token-meter.ps1 index 03d204d..3138c27 100644 --- a/scripts/start-token-meter.ps1 +++ b/scripts/start-token-meter.ps1 @@ -38,6 +38,11 @@ $TrayPidPath = Join-Path $RuntimeRoot "tray.pid" $TrayStatusPath = Join-Path $RuntimeRoot "tray.status.json" $TrayOutputLog = Join-Path $RuntimeRoot "tray.log" $TrayErrorLog = Join-Path $RuntimeRoot "tray.err.log" +$InstallModePath = Join-Path $RuntimeRoot "INSTALL_MODE" +$BackendOnly = $false +if (Test-Path -LiteralPath $InstallModePath -PathType Leaf) { + $BackendOnly = (Get-Content -LiteralPath $InstallModePath -Raw).Trim() -eq "backend-only" +} if ($ReadinessTimeoutSeconds -le 0) { $ConfiguredTimeout = 0 @@ -129,6 +134,10 @@ if (-not $Ready) { throw "Token Meter did not finish indexing within $ReadinessTimeoutSeconds seconds." } +if ($BackendOnly) { + return +} + if (-not (Test-Path -LiteralPath $TrayScript -PathType Leaf)) { throw "Token Meter's Windows tray launcher is missing. Reinstall Token Meter." } diff --git a/scripts/update b/scripts/update index 01e7c03..6b4e559 100755 --- a/scripts/update +++ b/scripts/update @@ -188,8 +188,16 @@ if (( behind > 0 )); then fi write_status "installing" "" "$current_revision" "$latest_revision" "$previous_revision" -if ! TOKEN_METER_INSTALL_ROOT="$RUNTIME_ROOT" "$INSTALL_SCRIPT"; then - fail_update "install_failed" +install_mode_argument="" +if [[ "$(cat "$RUNTIME_ROOT/INSTALL_MODE" 2>/dev/null || true)" == "backend-only" ]]; then + install_mode_argument="--backend-only" +fi +if [[ -n "$install_mode_argument" ]]; then + TOKEN_METER_INSTALL_ROOT="$RUNTIME_ROOT" "$INSTALL_SCRIPT" "$install_mode_argument" \ + || fail_update "install_failed" +else + TOKEN_METER_INSTALL_ROOT="$RUNTIME_ROOT" "$INSTALL_SCRIPT" \ + || fail_update "install_failed" fi write_status "complete" "" "$current_revision" "$latest_revision" "$previous_revision" diff --git a/scripts/update-windows.ps1 b/scripts/update-windows.ps1 index c18f0b9..7f78e77 100644 --- a/scripts/update-windows.ps1 +++ b/scripts/update-windows.ps1 @@ -138,7 +138,13 @@ if ($script:CurrentRevision -ne $script:LatestRevision) { } Write-UpdateStatus "installing" "" $script:CurrentRevision $script:LatestRevision $script:PreviousRevision try { - & (Join-Path $SourceRoot "scripts\install-windows.ps1") -InstallRoot $RuntimeRoot + $InstallArguments = @{ InstallRoot = $RuntimeRoot } + $InstallModePath = Join-Path $RuntimeRoot "INSTALL_MODE" + if ((Test-Path -LiteralPath $InstallModePath -PathType Leaf) -and + (Get-Content -LiteralPath $InstallModePath -Raw).Trim() -eq "backend-only") { + $InstallArguments["BackendOnly"] = $true + } + & (Join-Path $SourceRoot "scripts\install-windows.ps1") @InstallArguments } catch { Fail-Update "install_failed" } diff --git a/specs/USER_GUIDE.md b/specs/USER_GUIDE.md index af54409..3632fe2 100644 --- a/specs/USER_GUIDE.md +++ b/specs/USER_GUIDE.md @@ -9,9 +9,10 @@ troubleshooting. For a product overview, start with the - Python 3.8 or newer. - At least one supported runtime if you want trace data. -- macOS: Swift toolchain, normally from Xcode Command Line Tools. -- Linux: `systemd --user`, GTK 3, PyGObject, and Ayatana AppIndicator. GNOME +- macOS companion: Swift toolchain, normally from Xcode Command Line Tools. +- Linux companion: GTK 3, PyGObject, and Ayatana AppIndicator. GNOME generally needs an AppIndicator/KStatusNotifierItem extension. +- Linux server: `systemd --user`. - Windows extension (beta): Windows PowerShell and WinGet from Microsoft App Installer. The bootstrap installs missing Git and native Windows Python 3.8 or newer. @@ -33,6 +34,18 @@ The installer selects the current operating system, stages a stable per-user runtime outside the clone, starts the local server and native companion, waits for readiness, and configures automatic startup. +To install only the local server and browser dashboard, without the macOS +menu-bar or Linux tray companion, run: + +```bash +./token-meter/scripts/install --backend-only +``` + +Backend-only mode does not require the Swift toolchain or Linux +GTK/AppIndicator packages. It removes a previously installed native companion, +keeps the server configured for automatic startup, and remains backend-only +after automatic updates. + ### Windows > **Beta:** The Windows extension is still in beta. @@ -56,6 +69,18 @@ For a local rerun from an existing checkout, use `.\scripts\install-windows.cmd`. Pass `-NoOpenDashboard` to the downloaded script invocation when an unattended bootstrap should not open the dashboard. +To install only the local server and browser dashboard without the Windows notification-area companion, +pass `-BackendOnly` to the downloaded bootstrap +invocation (`& $p -BackendOnly`) or run: + +```powershell +.\scripts\install-windows.cmd -BackendOnly +``` + +Backend-only mode removes a running Token Meter tray process, keeps the server +configured for automatic startup, and remains backend-only after automatic +updates. + ### Run without installing For development or troubleshooting: diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index 988c8b2..77d00cc 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -185,6 +185,37 @@ def test_windows_lifecycle_is_per_user_owned_and_local_only(self): self.assertIn("belongs to a different", uninstaller) self.assertNotIn("sudo", installer.lower()) + def test_windows_backend_only_mode_skips_the_tray_and_survives_updates(self): + bootstrap = (ROOT / "scripts" / "bootstrap-windows.ps1").read_text(encoding="utf-8") + installer = (ROOT / "scripts" / "install-windows.ps1").read_text(encoding="utf-8") + starter = (ROOT / "scripts" / "start-token-meter.ps1").read_text(encoding="utf-8") + updater = (ROOT / "scripts" / "update-windows.ps1").read_text(encoding="utf-8") + + self.assertIn("[switch]$BackendOnly", bootstrap) + self.assertIn('$InstallerArguments += "-BackendOnly"', bootstrap) + self.assertIn("[switch]$BackendOnly", installer) + self.assertIn('$InstallMode = if ($BackendOnly) { "backend-only" } else { "full" }', installer) + self.assertIn('Join-Path $StagingRoot "INSTALL_MODE"', installer) + self.assertIn("if (-not $BackendOnly) {", installer) + self.assertIn('Backend-only installation: notification-area companion skipped.', installer) + + mode_read = starter.index('Join-Path $RuntimeRoot "INSTALL_MODE"') + backend_return = starter.index("if ($BackendOnly) {") + tray_start = starter.index("$TrayProcess = $null") + self.assertLess(mode_read, backend_return) + self.assertLess(backend_return, tray_start) + + self.assertIn('Join-Path $RuntimeRoot "INSTALL_MODE"', updater) + self.assertIn('$InstallArguments["BackendOnly"] = $true', updater) + + def test_windows_docs_explain_the_backend_only_switch(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + guide = (ROOT / "specs" / "USER_GUIDE.md").read_text(encoding="utf-8") + + for document in (readme, guide): + self.assertIn("-BackendOnly", document) + self.assertIn("notification-area companion", document) + def test_windows_tray_uses_runtime_catalog_for_known_and_unknown_labels(self): tray = (ROOT / "scripts" / "run-tray.ps1").read_text(encoding="utf-8") self.assertIn("function Get-RuntimeLabel", tray) diff --git a/tests/test_installer_modes.py b/tests/test_installer_modes.py new file mode 100644 index 0000000..68fe744 --- /dev/null +++ b/tests/test_installer_modes.py @@ -0,0 +1,281 @@ +import os +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(os.name == "posix", "POSIX shell integration") +class InstallerModeTests(unittest.TestCase): + def write_executable(self, path, source): + path.write_text(textwrap.dedent(source).lstrip()) + path.chmod(0o755) + + def argument_environment(self, workspace, platform="TestOS"): + fake_bin = workspace / "bin" + fake_bin.mkdir() + self.write_executable( + fake_bin / "uname", + f""" + #!/usr/bin/env bash + printf '%s\\n' {platform!r} + """, + ) + return { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + } + + def test_installers_expose_backend_only_help_before_platform_checks(self): + for relative_path in ("scripts/install", "scripts/install-linux"): + with self.subTest(installer=relative_path), tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + result = subprocess.run( + ["bash", str(ROOT / relative_path), "--help"], + capture_output=True, + text=True, + env=self.argument_environment(workspace), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--backend-only", result.stdout) + + def test_installers_reject_unknown_options_before_platform_checks(self): + for relative_path in ("scripts/install", "scripts/install-linux"): + with self.subTest(installer=relative_path), tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + result = subprocess.run( + ["bash", str(ROOT / relative_path), "--not-an-install-option"], + capture_output=True, + text=True, + env=self.argument_environment(workspace), + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("Unknown option: --not-an-install-option", result.stderr) + + def installer_environment(self, workspace, platform): + fake_bin = workspace / "bin" + fake_bin.mkdir() + command_log = workspace / "commands.log" + install_root = workspace / "runtime" + home = workspace / "home" + home.mkdir() + xdg_config = workspace / "config" + real_python = subprocess.run( + ["bash", "-lc", "command -v python3"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + self.write_executable( + fake_bin / "uname", + f""" + #!/usr/bin/env bash + printf '%s\\n' {platform!r} + """, + ) + self.write_executable( + fake_bin / "python3", + f""" + #!/usr/bin/env bash + if [[ "$*" == *bootstrap_git_delivery* ]]; then + printf '{{"ok": true}}\\n' + exit 0 + fi + if [[ "$*" == *token_meter_tray.py*" --check"* ]]; then + exit 0 + fi + exec {real_python!r} "$@" + """, + ) + self.write_executable( + fake_bin / "curl", + """ + #!/usr/bin/env bash + if [[ "${*: -1}" == */health ]]; then + printf '{"ok": true, "state_ready": true, "page_path": "%s/page.html"}\n' "$TEST_INSTALL_ROOT" + else + printf '{"ok": true}\n' + fi + """, + ) + self.write_executable( + fake_bin / "ditto", + """ + #!/usr/bin/env bash + if [[ -d "$1" ]]; then + mkdir -p "$2" + cp -Rp "$1/." "$2/" + else + cp -p "$1" "$2" + fi + """, + ) + self.write_executable( + fake_bin / "swiftc", + """ + #!/usr/bin/env bash + exit 0 + """, + ) + self.write_executable( + fake_bin / "launchctl", + """ + #!/usr/bin/env bash + printf 'launchctl %s\n' "$*" >> "$TEST_COMMAND_LOG" + if [[ "${1:-}" == "print" ]]; then + if [[ "$*" == *com.token-meter.menubar* ]]; then + printf '%s/scripts/run-menubar\n' "$TEST_INSTALL_ROOT" + else + printf '%s/meter.py\n' "$TEST_INSTALL_ROOT" + fi + fi + """, + ) + self.write_executable( + fake_bin / "systemctl", + """ + #!/usr/bin/env bash + printf 'systemctl %s\n' "$*" >> "$TEST_COMMAND_LOG" + if [[ "$*" == *"show token-meter-server.service"* ]]; then + printf '%s/systemd/user/token-meter-server.service\n' "$XDG_CONFIG_HOME" + elif [[ "$*" == *"show token-meter-tray.service"* ]]; then + printf '%s/systemd/user/token-meter-tray.service\n' "$XDG_CONFIG_HOME" + fi + """, + ) + self.write_executable( + fake_bin / "journalctl", + """ + #!/usr/bin/env bash + printf 'journalctl %s\n' "$*" >> "$TEST_COMMAND_LOG" + """, + ) + bash_env = workspace / "bash-env" + bash_env.write_text( + "command() {\n" + " if [[ \"${1:-}\" == \"-v\" && \"${2:-}\" == \"swiftc\" ]]; then return 1; fi\n" + " builtin command \"$@\"\n" + "}\n" + ) + return { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(xdg_config), + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "BASH_ENV": str(bash_env), + "TOKEN_METER_INSTALL_ROOT": str(install_root), + "TOKEN_METER_SOURCE_ROOT": str(ROOT), + "TOKEN_METER_READINESS_TIMEOUT_SECONDS": "2", + "TEST_COMMAND_LOG": str(command_log), + "TEST_INSTALL_ROOT": str(install_root), + }, install_root, command_log + + def test_macos_default_install_keeps_the_native_companion(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + env, install_root, command_log = self.installer_environment(workspace, "Darwin") + env.pop("BASH_ENV") + launch_agents = Path(env["HOME"]) / "Library" / "LaunchAgents" + + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "install")], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((launch_agents / "com.token-meter.server.plist").is_file()) + self.assertTrue((launch_agents / "com.token-meter.menubar.plist").is_file()) + self.assertEqual((install_root / "INSTALL_MODE").read_text(), "full\n") + self.assertIn( + "bootstrap gui/", "\n".join( + line for line in command_log.read_text().splitlines() + if "com.token-meter.menubar" in line + ), + ) + + def test_linux_default_install_keeps_the_tray_companion(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + env, install_root, command_log = self.installer_environment(workspace, "Linux") + env.pop("BASH_ENV") + systemd_dir = Path(env["XDG_CONFIG_HOME"]) / "systemd" / "user" + + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "install-linux")], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((systemd_dir / "token-meter-server.service").is_file()) + self.assertTrue((systemd_dir / "token-meter-tray.service").is_file()) + self.assertEqual((install_root / "INSTALL_MODE").read_text(), "full\n") + self.assertIn( + "is-active --quiet token-meter-tray.service", + command_log.read_text(), + ) + + def test_macos_backend_only_install_skips_swift_and_removes_companion_job(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + env, install_root, command_log = self.installer_environment(workspace, "Darwin") + launch_agents = Path(env["HOME"]) / "Library" / "LaunchAgents" + launch_agents.mkdir(parents=True) + (launch_agents / "com.token-meter.menubar.plist").write_text("old companion") + + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "install"), "--backend-only"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((launch_agents / "com.token-meter.server.plist").is_file()) + self.assertFalse((launch_agents / "com.token-meter.menubar.plist").exists()) + self.assertEqual((install_root / "INSTALL_MODE").read_text(), "backend-only\n") + self.assertNotIn("bootstrap gui/", "\n".join( + line for line in command_log.read_text().splitlines() + if "com.token-meter.menubar" in line + )) + self.assertIn("Backend-only installation: native companion skipped.", result.stdout) + + def test_linux_backend_only_install_skips_tray_check_and_removes_tray_unit(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + env, install_root, command_log = self.installer_environment(workspace, "Linux") + systemd_dir = Path(env["XDG_CONFIG_HOME"]) / "systemd" / "user" + systemd_dir.mkdir(parents=True) + (systemd_dir / "token-meter-tray.service").write_text("old companion") + + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "install-linux"), "--backend-only"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((systemd_dir / "token-meter-server.service").is_file()) + self.assertFalse((systemd_dir / "token-meter-tray.service").exists()) + self.assertEqual((install_root / "INSTALL_MODE").read_text(), "backend-only\n") + self.assertNotIn("is-active --quiet token-meter-tray.service", command_log.read_text()) + self.assertIn("Backend-only installation: native companion skipped.", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_linux_update.py b/tests/test_linux_update.py index 96f3cf3..a540908 100644 --- a/tests/test_linux_update.py +++ b/tests/test_linux_update.py @@ -31,6 +31,7 @@ def create_checkout(self, workspace): "#!/usr/bin/env bash\n" "set -euo pipefail\n" "printf '%s\\n' \"$TOKEN_METER_INSTALL_ROOT\" > \"$INSTALL_LOG\"\n" + "printf '%s\\n' \"$@\" >> \"$INSTALL_LOG\"\n" "touch \"$INSTALL_MARKER\"\n" ) install_script.chmod(0o755) @@ -103,6 +104,29 @@ def test_fast_forwards_and_invokes_linux_installer_through_dispatcher(self): self.assertEqual((source / "version.txt").read_text(), "two\n") self.assertEqual(json.loads(status_path.read_text())["phase"], "complete") + def test_backend_only_runtime_keeps_its_mode_after_update(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + marker = workspace / "installer-marker" + install_log = workspace / "installer.log" + seed, source = self.create_checkout(workspace) + self.add_remote_update(seed) + runtime = self.copy_dispatcher_runtime(workspace) + (runtime / "INSTALL_MODE").write_text("backend-only\n") + status_path = workspace / "status.json" + + result = self.run_update( + runtime, source, status_path, + INSTALL_LOG=str(install_log), INSTALL_MARKER=str(marker), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + install_log.read_text().splitlines(), + [str(runtime), "--backend-only"], + ) + self.assertEqual(json.loads(status_path.read_text())["phase"], "complete") + def test_safety_failures_leave_source_unchanged(self): for case in ("dirty", "diverged", "non_main"): with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp: