From bd5830159b6967bd5e32a44fd3483366bf2d74bd Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 18 Aug 2026 17:28:07 +0200 Subject: [PATCH 1/4] Windows installers: same existing-account gate as install.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4996 taught `install.sh` to detect a machine that is already linked to a ClawMetry account, report that setup, and ask before replaying the onboarding wizard. The Windows installers are the same one-liner promise on another OS, so they get the same contract: You're already connected to ClawMetry Account: founder@example.com (Pro plan) Cloud sync: Local-only (data stays on this machine) Version: 0.12.737 Node: ci-box Dashboard: http://localhost:8900 Re-run setup (account, cloud vs local-only, license)? [y/N]: Two things had to be fixed first for that to work at all on Windows: 1. Neither Windows installer ever ran `clawmetry onboard`. A Windows user was installed but never set up (no account, no daemon, no dashboard) unless they found `clawmetry connect` in the docs. Both scripts now onboard when no account is linked, exactly like macOS/Linux, and gate it behind the question when one is. 2. `install.ps1` wiped and rebuilt the venv on every run. Windows locks the files of a running process, so on the very machine this feature targets (an install whose daemon is live) `Remove-Item -Recurse` fails and the install aborts. It now upgrades in place, and only rebuilds when there is no usable venv. The pre-flight stops ClawMetry's own processes (the sync daemon, and anything running out of the install dir) before pip replaces their files and restarts the daemon on the new code afterwards; a dashboard the operator was running is reported, not silently resurrected (same as macOS/Linux). Shared contract across all three installers: - connected = an API key on this machine; a placeholder (…@clawmetry.auto / …@clawmetry.linked) account does NOT count, so that node still gets the wizard - probe prefers `clawmetry status --json`, falls back to config.json + cloud_plan.json + the nocloud marker, and degrades to "not connected" on any failure rather than breaking the install - default is keep-my-setup: empty answer, "n", or a non-interactive run changes nothing; CLAWMETRY_REONBOARD=1/0 forces it either way; CLAWMETRY_SKIP_ONBOARD=1 skips onboarding entirely; CLAWMETRY_LOCAL_ONLY=1 writes the nocloud marker - install.ps1 parses `status --json` with ConvertFrom-Json; install.cmd hands the work to one python probe that prints the summary and returns the answer in its exit code, so batch never parses JSON Verification: - tests/test_windows_installer_existing_account_gate.py — 25 tests: the PowerShell helpers driven under pwsh (fresh / local-only-no-account / placeholder / corrupt config / config-file fallback / snapshot preferred / dead dashboard / env overrides / non-interactive), the install.cmd python probe executed directly on any OS, and static parity guards across all three installers - .github/workflows/install-test.yml — real windows-latest runners now assert the gate end to end: a synthetic connected profile must be reported and left alone, and a fresh profile must still get the wizard Co-Authored-By: Claude Opus 5 --- .github/workflows/install-test.yml | 64 +++ install.cmd | 51 +++ install.ps1 | 341 +++++++++++++- ...windows_installer_existing_account_gate.py | 425 ++++++++++++++++++ 4 files changed, 859 insertions(+), 22 deletions(-) create mode 100644 tests/test_windows_installer_existing_account_gate.py diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index 4c5df84b4d..b3d600acdc 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -46,6 +46,7 @@ jobs: test-windows-ps: runs-on: windows-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - name: Set up Python @@ -54,15 +55,55 @@ jobs: python-version: '3.12' - name: Run PowerShell install shell: pwsh + env: + CLAWMETRY_SKIP_ONBOARD: '1' run: .\install.ps1 - name: Verify clawmetry binary shell: pwsh run: | $env:PATH = "$env:LOCALAPPDATA\clawmetry\Scripts;$env:PATH" clawmetry --version + # Re-running the installer on a node that is ALREADY linked to an account + # must report that setup and leave it alone -- not replay the wizard. + # USERPROFILE points the probe at a synthetic profile, so this exercises + # the real script without depending on runner state. + - name: Gate — connected node keeps its setup + shell: pwsh + env: + CLAWMETRY_REONBOARD: '0' + run: | + $profileDir = Join-Path $env:RUNNER_TEMP "cm-connected" + $dataDir = Join-Path $profileDir ".clawmetry" + New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + Set-Content -Path (Join-Path $dataDir "config.json") -Value '{"api_key":"cm_abc123456789","node_id":"ci-box","account_email":"founder@example.com"}' + Set-Content -Path (Join-Path $dataDir "cloud_plan.json") -Value '{"plan":"cloud_pro"}' + New-Item -ItemType File -Force -Path (Join-Path $dataDir "nocloud") | Out-Null + $env:USERPROFILE = $profileDir + $out = (.\install.ps1 6>&1 | Out-String) + Write-Host $out + if ($out -notmatch "already connected") { throw "no existing-setup summary" } + if ($out -notmatch "founder@example.com") { throw "account email not reported" } + if ($out -notmatch "Pro plan") { throw "plan not reported" } + if ($out -notmatch "Local-only") { throw "cloud sync mode not reported" } + if ($out -notmatch "Keeping your current setup") { throw "did not keep the existing setup" } + if ($out -match "How do you want to run ClawMetry") { throw "wizard replayed over an existing setup" } + # A node with NO account keeps the original behaviour: the wizard runs. + - name: Gate — fresh node still gets the wizard + shell: pwsh + env: + CLAWMETRY_LOCAL_ONLY: '1' + run: | + $profileDir = Join-Path $env:RUNNER_TEMP "cm-fresh" + New-Item -ItemType Directory -Force -Path $profileDir | Out-Null + $env:USERPROFILE = $profileDir + $out = (.\install.ps1 6>&1 | Out-String) + Write-Host $out + if ($out -match "already connected") { throw "claimed a connection that does not exist" } + if ($out -notmatch "How do you want to run ClawMetry") { throw "onboarding wizard did not run" } test-windows-cmd: runs-on: windows-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - name: Set up Python @@ -71,10 +112,33 @@ jobs: python-version: '3.12' - name: Run CMD install shell: cmd + env: + CLAWMETRY_SKIP_ONBOARD: '1' run: install.cmd - name: Verify clawmetry binary shell: cmd run: clawmetry --version + # Same gate as the PowerShell path: an already-connected node is reported + # and left alone. CLAWMETRY_REONBOARD=0 answers the question up front so + # the step never waits on input. + - name: Gate — connected node keeps its setup + shell: cmd + env: + CLAWMETRY_REONBOARD: '0' + run: | + mkdir "%RUNNER_TEMP%\cm-connected\.clawmetry" + > "%RUNNER_TEMP%\cm-connected\.clawmetry\config.json" echo {"api_key":"cm_abc123456789","node_id":"ci-box","account_email":"founder@example.com"} + > "%RUNNER_TEMP%\cm-connected\.clawmetry\cloud_plan.json" echo {"plan":"cloud_pro"} + type nul > "%RUNNER_TEMP%\cm-connected\.clawmetry\nocloud" + set "USERPROFILE=%RUNNER_TEMP%\cm-connected" + install.cmd > "%RUNNER_TEMP%\cmd-gate.log" 2>&1 + type "%RUNNER_TEMP%\cmd-gate.log" + findstr /C:"already connected" "%RUNNER_TEMP%\cmd-gate.log" >nul || exit /b 1 + findstr /C:"founder@example.com" "%RUNNER_TEMP%\cmd-gate.log" >nul || exit /b 1 + findstr /C:"Pro plan" "%RUNNER_TEMP%\cmd-gate.log" >nul || exit /b 1 + findstr /C:"Keeping your current setup" "%RUNNER_TEMP%\cmd-gate.log" >nul || exit /b 1 + findstr /C:"How do you want to run ClawMetry" "%RUNNER_TEMP%\cmd-gate.log" >nul && exit /b 1 + exit /b 0 shellcheck: runs-on: ubuntu-latest diff --git a/install.cmd b/install.cmd index 80db971cc1..cd4bd10d4e 100644 --- a/install.cmd +++ b/install.cmd @@ -67,6 +67,57 @@ if %ERRORLEVEL% NEQ 0 ( ) echo ✓ Installed clawmetry +echo. + +REM ── Existing setup: account probe + "re-onboard?" gate ────────────────── +REM Same contract as install.sh / install.ps1: this script is also the upgrade +REM path, so on a machine that is ALREADY linked to a ClawMetry account it +REM reports that setup and asks before replaying the wizard over it. With no +REM account linked it runs `clawmetry onboard` straight away. +REM +REM The probe below both PRINTS the summary and carries the answer in its exit +REM code (0 = connected), so batch never has to parse JSON. Any failure -- an +REM unreadable config, a CLI too old for `status --json`, no network -- exits +REM non-zero, which means "not connected" and simply runs the wizard as before. +if "%CLAWMETRY_SKIP_ONBOARD%"=="1" ( + echo Skipping onboard ^(CLAWMETRY_SKIP_ONBOARD=1^) - set up later with: clawmetry onboard + goto :cm_onboard_done +) + +set "CM_STATUS_FILE=%TEMP%\clawmetry-status.json" +%PYTHON% -m clawmetry status --json > "%CM_STATUS_FILE%" 2>nul +%PYTHON% -c "import json,os;h=os.path.expanduser('~');d=os.path.join(h,'.clawmetry');g=lambda p:(json.loads(open(p).read()) if p and os.path.exists(p) else {});c=g(os.path.join(d,'config.json'));s=g(os.environ.get('CM_STATUS_FILE',''));cl=s.get('cloud_sync') or {};a=cl.get('account') or {};k=str(c.get('api_key') or '') or os.environ.get('CLAWMETRY_API_KEY','');e=str(a.get('email') or c.get('account_email') or '');ph=bool(a.get('placeholder')) or e.lower().endswith(('@clawmetry.auto','@clawmetry.linked'));conn=(bool(k) or bool(cl.get('api_key_masked'))) and not ph;pl=str(a.get('plan') or g(os.path.join(d,'cloud_plan.json')).get('plan') or '');lo=cl.get('local_only');lo=(bool(c.get('local_only')) or os.path.exists(os.path.join(d,'nocloud'))) if lo is None else bool(lo);L={'oss':'OSS','cloud_free':'Free','free':'Free','trial':'Trial','cloud_starter':'Starter','cloud_pro':'Pro','pro':'Self-hosted Pro','enterprise':'Enterprise'};lab=L.get(pl.lower(),pl.replace('cloud_','').replace('_',' ').title());n=str(cl.get('node_id') or c.get('node_id') or '');v=str(s.get('version') or '');out=[];out.append(' You are already connected to ClawMetry');out.append('');out.append(' Account: '+e+(' ['+lab+' plan]' if lab else '')) if e else None;out.append(' Cloud sync: '+('Local-only, data stays on this machine' if lo else 'On, syncing to app.clawmetry.com'));out.append(' Version: '+v) if v else None;out.append(' Node: '+n) if n else None;print(chr(10).join(out)) if conn else None;raise SystemExit(0 if conn else 1)" 2>nul +set "CM_PROBE_RC=%ERRORLEVEL%" +del "%CM_STATUS_FILE%" >nul 2>&1 +set "CM_STATUS_FILE=" + +if not "%CM_PROBE_RC%"=="0" ( + %PYTHON% -m clawmetry onboard + goto :cm_onboard_done +) + +REM CLAWMETRY_REONBOARD forces the answer without asking; anything else asks, +REM and an empty answer (including a non-interactive run, where `set /p` +REM returns immediately) keeps the setup that is already on this machine. +if /I "%CLAWMETRY_REONBOARD%"=="1" goto :cm_reonboard +if /I "%CLAWMETRY_REONBOARD%"=="0" goto :cm_keep +echo. +set "CM_ANS=" +set /p "CM_ANS= Re-run setup [account, cloud vs local-only, license]? [y/N]: " +if /I "%CM_ANS%"=="y" goto :cm_reonboard +if /I "%CM_ANS%"=="yes" goto :cm_reonboard + +:cm_keep +echo. +echo Keeping your current setup. +echo Change it anytime: clawmetry onboard +goto :cm_onboard_done + +:cm_reonboard +%PYTHON% -m clawmetry onboard + +:cm_onboard_done + echo. echo Ready! Run 'clawmetry' to start the dashboard. echo Then open http://localhost:8900 in your browser. diff --git a/install.ps1 b/install.ps1 index d79df35650..736a9ccd67 100644 --- a/install.ps1 +++ b/install.ps1 @@ -37,6 +37,54 @@ Write-Host "→ Using $python ($(& $python --version 2>&1))" # Install directory $installDir = "$env:LOCALAPPDATA\clawmetry" +# Operator data (config.json, the DuckDB store, sync state) lives OUTSIDE the +# venv, in the home directory, and must survive every upgrade. +$dataDir = "$env:USERPROFILE\.clawmetry" + +# ── Pre-flight: stop a running install before touching its files ───────── +# Windows locks the files of a running process. Re-running this installer on a +# machine where the sync daemon is live (the common case: this script is also +# the upgrade path) fails on "file in use" the moment pip or Remove-Item tries +# to replace `python.exe`/a loaded `.pyd`. Stop the scheduled task and any +# leftover clawmetry process first, remember that it WAS running, and start it +# again once the new code is in place. +$cmTaskName = "ClawMetrySyncDaemon" +$cmTaskRegistered = $false +try { + & schtasks /query /tn $cmTaskName 2>&1 | Out-Null + $cmTaskRegistered = ($LASTEXITCODE -eq 0) +} catch {} + +# Only ClawMetry's OWN processes are stopped: the sync daemon, and anything +# running out of the install dir whose files this upgrade replaces. A python +# script of the operator's that merely imports clawmetry is left alone. +$cmDaemonWasRunning = $false +$cmOtherStopped = 0 +$cmProcs = @() +try { + $cmProcs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.ProcessId -ne $PID -and ( + $_.CommandLine -match "clawmetry\.sync" -or + $_.CommandLine -like "*$installDir*" + ) + }) +} catch { $cmProcs = @() } + +foreach ($proc in $cmProcs) { + if ($proc.CommandLine -match "clawmetry\.sync") { $cmDaemonWasRunning = $true } + else { $cmOtherStopped++ } +} + +if ($cmProcs.Count -gt 0) { + Write-Host "→ Stopping $($cmProcs.Count) running ClawMetry process(es) so the upgrade can replace their files..." + if ($cmTaskRegistered -and $cmDaemonWasRunning) { + try { & schtasks /end /tn $cmTaskName 2>&1 | Out-Null } catch {} + } + foreach ($proc in $cmProcs) { + try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch {} + } + Start-Sleep -Seconds 1 +} # ── Stale-duplicate sweep ──────────────────────────────────────────────── # The dedicated venv above is the ONLY environment auto-update keeps current. @@ -79,26 +127,39 @@ foreach ($pyExe in $pyCandidates) { } catch {} } -# Remove old install for clean state -if (Test-Path $installDir) { - Write-Host "→ Removing previous installation..." - Remove-Item -Recurse -Force $installDir -} - -# Create venv -Write-Host "→ Creating virtual environment at $installDir..." -& $python -m venv $installDir -if ($LASTEXITCODE -ne 0) { - Write-Host "❌ Failed to create virtual environment." -ForegroundColor Red - exit 1 +# Upgrade the existing venv in place; only rebuild when there isn't a usable +# one. Wiping it on every run threw away a working environment (and, with a +# live daemon, failed outright on locked files) for no benefit -- pip upgrades +# the distribution just fine. +$venvPython = "$installDir\Scripts\python.exe" +if ((Test-Path $venvPython) -and (Test-Path "$installDir\pyvenv.cfg")) { + Write-Host "→ Upgrading the existing install at $installDir..." +} else { + if (Test-Path $installDir) { + Write-Host "→ Removing previous (incomplete) installation..." + try { + Remove-Item -Recurse -Force $installDir -ErrorAction Stop + } catch { + Write-Host "❌ Could not remove $installDir (a ClawMetry process may still be running)." -ForegroundColor Red + Write-Host " Close it and re-run this installer." -ForegroundColor Red + exit 1 + } + } + # Create venv + Write-Host "→ Creating virtual environment at $installDir..." + & $python -m venv $installDir + if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Failed to create virtual environment." -ForegroundColor Red + exit 1 + } } # Upgrade pip (using python -m pip to avoid in-use upgrade error on Windows) -& "$installDir\Scripts\python.exe" -m pip install --upgrade pip 2>&1 | Out-Null +& $venvPython -m pip install --upgrade pip 2>&1 | Out-Null -# Install clawmetry +# Install/upgrade clawmetry (python -m pip, so a running pip.exe can't lock it) Write-Host "→ Installing clawmetry from PyPI..." -& "$installDir\Scripts\pip.exe" install --no-cache-dir clawmetry 2>&1 | Out-Null +& $venvPython -m pip install --no-cache-dir --upgrade clawmetry 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Write-Host "❌ Failed to install clawmetry." -ForegroundColor Red exit 1 @@ -120,27 +181,263 @@ if (Test-Path $openclawDir) { $workspace = $openclawDir } +# Restart the daemon we stopped in the pre-flight so it runs the NEW code. +# (A daemon left down after an upgrade is the "my node went quiet after +# updating" bug; a daemon left up is running the version we just replaced.) +if ($cmDaemonWasRunning) { + Write-Host "→ Restarting the ClawMetry daemon..." + if ($cmTaskRegistered) { + try { & schtasks /run /tn $cmTaskName 2>&1 | Out-Null } catch {} + } else { + try { + Start-Process -FilePath $venvPython -ArgumentList "-m", "clawmetry.sync" -WindowStyle Hidden | Out-Null + } catch {} + } +} + # Get version $version = "installed" try { $version = & "$binDir\clawmetry.exe" --version 2>&1 } catch {} +if ($cmOtherStopped -gt 0) { + Write-Host "→ Your dashboard was stopped for the upgrade. Start it again with: clawmetry" +} + Write-Host "" Write-Host "✅ Clawmetry installed successfully!" -ForegroundColor Green Write-Host "" Write-Host " Version: $version" -Write-Host "" -Write-Host " Start with:" -Write-Host " clawmetry --host 0.0.0.0 --port 8900" -ForegroundColor White -Write-Host "" if ($workspace) { Write-Host " OpenClaw workspace detected: $workspace" +} +Write-Host "" + +# >>> CM_EXISTING_SETUP_BLOCK_START (tests source everything between these +# sentinels; keep them around the helpers) >>> +# ── Existing setup: account probe + "re-onboard?" gate ────────────────── +# Re-running this installer on a machine that is ALREADY set up used to say +# nothing about the setup already on disk. It is also the upgrade path, so on +# a connected machine the useful thing to do is report what this node is +# linked to and offer the setup wizard, not replay it blind. A machine with NO +# account linked goes straight into `clawmetry onboard`, same as macOS/Linux. +# +# Mirrors the block of the same name in install.sh; keep the two in step. + +function Get-ClawmetryTierLabel { + param([string]$Tier) + switch (($Tier + "").Trim().ToLowerInvariant()) { + "" { return "" } + "oss" { return "OSS" } + "cloud_free" { return "Free" } + "free" { return "Free" } + "trial" { return "Trial" } + "cloud_starter" { return "Starter" } + "cloud_pro" { return "Pro" } + "pro" { return "Self-hosted Pro" } + "enterprise" { return "Enterprise" } + default { return (Get-Culture).TextInfo.ToTitleCase($Tier.Replace("cloud_", "").Replace("_", " ")) } + } +} + +# Never promise a dashboard URL that nothing answers on, and never guess the +# port: the daemon records the live one in server.json. Any HTTP answer -- +# including 401/302 -- counts as "up". +function Get-ClawmetryDashboardUrl { + param([string]$DataDir) + $ports = @() + try { + $srv = Join-Path $DataDir "server.json" + if (Test-Path $srv) { + $port = (Get-Content -Raw $srv | ConvertFrom-Json).port + if ($port) { $ports += [int]$port } + } + } catch {} + if ($ports -notcontains 8900) { $ports += 8900 } + foreach ($port in $ports) { + try { + Invoke-WebRequest -Uri "http://127.0.0.1:$port/" -TimeoutSec 2 -UseBasicParsing | Out-Null + return "http://localhost:$port" + } catch { + if ($_.Exception.Response) { return "http://localhost:$port" } + } + } + return "" +} + +# Prefers the installed CLI's `status --json` (authoritative: it resolves the +# live account email/plan and honours every local-only signal) and falls back +# to the config files on disk. Never throws: every broken corner degrades to +# "not connected", which just runs the wizard as before. +function Get-ClawmetryExistingSetup { + param([string]$ClawmetryExe, [string]$DataDir) + + $setup = [ordered]@{ + Connected = $false; Email = ""; Plan = ""; Sync = "cloud" + Node = ""; Version = ""; E2E = $false; Dashboard = "" + } + + $snap = $null + try { + if (Test-Path $ClawmetryExe) { + $raw = (& $ClawmetryExe status --json 2>$null | Out-String) + if ($raw.Trim()) { $snap = $raw | ConvertFrom-Json } + } + } catch {} + + $cfg = $null + try { + $cfgPath = Join-Path $DataDir "config.json" + if (Test-Path $cfgPath) { $cfg = Get-Content -Raw $cfgPath | ConvertFrom-Json } + } catch {} + + $cloud = $null + $acct = $null + if ($snap) { $cloud = $snap.cloud_sync } + if ($cloud) { $acct = $cloud.account } + + $apiKey = "" + if ($cfg -and $cfg.api_key) { $apiKey = [string]$cfg.api_key } + if (-not $apiKey -and $env:CLAWMETRY_API_KEY) { $apiKey = $env:CLAWMETRY_API_KEY } + $connected = [bool]$apiKey + if (-not $connected -and $cloud -and $cloud.api_key_masked) { $connected = $true } + + $email = "" + if ($acct -and $acct.email) { $email = [string]$acct.email } + elseif ($cfg -and $cfg.account_email) { $email = [string]$cfg.account_email } + + # A placeholder account (…@clawmetry.auto / …@clawmetry.linked) is the + # daemon's zero-friction auto-registration, not the operator's login -- it + # is invisible from their dashboard, so treat it as "not connected" and + # let the wizard run. + $lowered = $email.ToLowerInvariant() + if (($acct -and $acct.placeholder) -or $lowered.EndsWith("@clawmetry.auto") -or $lowered.EndsWith("@clawmetry.linked")) { + $connected = $false + $email = "" + } + + $plan = "" + if ($acct -and $acct.plan) { $plan = [string]$acct.plan } + if (-not $plan) { + try { + $planPath = Join-Path $DataDir "cloud_plan.json" + if (Test-Path $planPath) { $plan = [string](Get-Content -Raw $planPath | ConvertFrom-Json).plan } + } catch {} + } + + $localOnly = $null + if ($cloud -and $cloud.PSObject.Properties.Name -contains "local_only") { $localOnly = $cloud.local_only } + if ($null -eq $localOnly) { + $localOnly = $false + if ($cfg -and $cfg.local_only) { $localOnly = $true } + if (Test-Path (Join-Path $DataDir "nocloud")) { $localOnly = $true } + if ($env:CLAWMETRY_NO_CLOUD -and @("1", "true", "yes", "on") -contains $env:CLAWMETRY_NO_CLOUD.ToLowerInvariant()) { $localOnly = $true } + } + + $setup.Connected = $connected + $setup.Email = $email + $setup.Plan = (Get-ClawmetryTierLabel $plan) + $setup.Sync = $(if ($localOnly) { "local-only" } else { "cloud" }) + if ($cloud -and $cloud.node_id) { $setup.Node = [string]$cloud.node_id } + elseif ($cfg -and $cfg.node_id) { $setup.Node = [string]$cfg.node_id } + if ($snap -and $snap.version) { $setup.Version = [string]$snap.version } + if ($cloud -and $cloud.encryption -and $cloud.encryption.enabled) { $setup.E2E = $true } + elseif ($cfg -and $cfg.encryption_key) { $setup.E2E = $true } + $setup.Dashboard = (Get-ClawmetryDashboardUrl -DataDir $DataDir) + return $setup +} + +# Show the setup that is already on this machine, so the operator can tell at +# a glance which account/plan this node reports to before changing anything. +function Show-ClawmetryExistingSetup { + param($Setup) Write-Host "" + Write-Host " ✓ You're already connected to ClawMetry" -ForegroundColor Green + Write-Host "" + if ($Setup.Email) { + if ($Setup.Plan) { + Write-Host " Account: $($Setup.Email) ($($Setup.Plan) plan)" + } else { + Write-Host " Account: $($Setup.Email)" + } + } + if ($Setup.Sync -eq "local-only") { + Write-Host " Cloud sync: Local-only (data stays on this machine)" + } elseif ($Setup.E2E) { + Write-Host " Cloud sync: On (E2E-encrypted snapshots to app.clawmetry.com)" + } else { + Write-Host " Cloud sync: On (app.clawmetry.com)" + } + if ($Setup.Version) { Write-Host " Version: $($Setup.Version)" } + if ($Setup.Node) { Write-Host " Node: $($Setup.Node)" } + if ($Setup.Dashboard) { + Write-Host " Dashboard: $($Setup.Dashboard)" + } else { + Write-Host " Dashboard: not running (start it: clawmetry)" + } + Write-Host "" +} + +# $true => re-run the wizard, $false => keep the current setup untouched. +# Never re-onboards without an explicit yes: a non-interactive re-install (CI, +# a provisioning script, `iex` with redirected input) keeps what is set up. +function Confirm-ClawmetryReonboard { + if ($env:CLAWMETRY_REONBOARD) { + $flag = $env:CLAWMETRY_REONBOARD.ToLowerInvariant() + if (@("1", "true", "yes", "on") -contains $flag) { return $true } + if (@("0", "false", "no", "off") -contains $flag) { return $false } + } + $interactive = $true + try { $interactive = (-not [Console]::IsInputRedirected) } catch {} + if (-not $interactive) { + Write-Host " Non-interactive install: keeping your current setup." + Write-Host " ↻ Change it anytime: clawmetry onboard" + return $false + } + $answer = "" + try { $answer = Read-Host " Re-run setup (account, cloud vs local-only, license)? [y/N]" } catch {} + if (@("y", "yes") -contains ($answer + "").Trim().ToLowerInvariant()) { return $true } + Write-Host "" + Write-Host " Keeping your current setup." + Write-Host " ↻ Change it anytime: clawmetry onboard" + return $false +} + +function Invoke-ClawmetryOnboard { + param([string]$ClawmetryExe) + try { & $ClawmetryExe onboard } catch {} +} +# <<< CM_EXISTING_SETUP_BLOCK_END <<< + +# Local-only opt-out: CLAWMETRY_LOCAL_ONLY=1 means "never create a cloud +# account, nothing leaves this machine". Write the persistent marker now so it +# holds even when onboarding is skipped (onboard itself also defaults local). +if ($env:CLAWMETRY_LOCAL_ONLY -and @("1", "true", "yes", "on") -contains $env:CLAWMETRY_LOCAL_ONLY.ToLowerInvariant()) { + try { + New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $dataDir "nocloud") | Out-Null + } catch {} + Write-Host " Local-only mode (CLAWMETRY_LOCAL_ONLY set): no cloud account will be created." } -Write-Host " Then open http://YOUR_IP:8900 in your browser" + +$clawmetryExe = "$binDir\clawmetry.exe" +if ($env:CLAWMETRY_SKIP_ONBOARD -eq "1") { + Write-Host " Skipping onboard (CLAWMETRY_SKIP_ONBOARD=1). Set up later with: clawmetry onboard" +} else { + $setup = Get-ClawmetryExistingSetup -ClawmetryExe $clawmetryExe -DataDir $dataDir + if ($setup.Connected) { + Show-ClawmetryExistingSetup -Setup $setup + if (Confirm-ClawmetryReonboard) { + Invoke-ClawmetryOnboard -ClawmetryExe $clawmetryExe + } + } else { + Invoke-ClawmetryOnboard -ClawmetryExe $clawmetryExe + } +} + Write-Host "" -Write-Host " To run in background (PowerShell):" -Write-Host " Start-Process clawmetry -ArgumentList '--host 0.0.0.0 --port 8900' -WindowStyle Hidden" -ForegroundColor White +Write-Host " Dashboard: clawmetry" -ForegroundColor White +Write-Host " then open http://localhost:8900 in your browser" Write-Host "" Write-Host "🔭 Happy observing!" -ForegroundColor Cyan diff --git a/tests/test_windows_installer_existing_account_gate.py b/tests/test_windows_installer_existing_account_gate.py new file mode 100644 index 0000000000..abde031896 --- /dev/null +++ b/tests/test_windows_installer_existing_account_gate.py @@ -0,0 +1,425 @@ +"""Windows parity for install.sh's "you're already connected" gate (2026-08-18). + +`install.sh` learned to detect an already-linked account, report it, and ask +before replaying `clawmetry onboard` (PR #4996). The Windows installers are the +same one-liner promise on another OS -- and they had a second problem: neither +`install.ps1` nor `install.cmd` ever ran onboarding at all, and `install.ps1` +wiped and rebuilt the venv on every run, which fails outright on a machine +whose daemon is live (Windows locks the files of a running process). + +These tests pin the Windows behaviour: + +* an already-connected node is reported and left alone unless the operator says + yes (`CLAWMETRY_REONBOARD` decides it without a prompt; a non-interactive run + keeps the setup); +* a node with no account linked goes straight into the wizard; +* a placeholder (auto-registered) account does not count as connected; +* the PowerShell installer upgrades the existing venv in place and restarts a + daemon it had to stop. + +The PowerShell behaviour is exercised with `pwsh` where it exists (GitHub's +ubuntu/macOS runners ship it); the end-to-end proof on real Windows lives in +`.github/workflows/install-test.yml`. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALL_PS1 = REPO_ROOT / "install.ps1" +INSTALL_CMD = REPO_ROOT / "install.cmd" +INSTALL_SH = REPO_ROOT / "install.sh" + +PWSH = shutil.which("pwsh") +needs_pwsh = pytest.mark.skipif(PWSH is None, reason="pwsh not installed") + + +def _ps1() -> str: + return INSTALL_PS1.read_text(encoding="utf-8") + + +def _cmd() -> str: + return INSTALL_CMD.read_text(encoding="utf-8") + + +def _helper_block() -> str: + body = _ps1() + start = body.index("# >>> CM_EXISTING_SETUP_BLOCK_START") + end = body.index("# <<< CM_EXISTING_SETUP_BLOCK_END") + return body[start:end] + + +def _run_pwsh(script: str, env_extra: dict | None = None, cwd: Path | None = None) -> str: + env = dict(os.environ) + for key in ("CLAWMETRY_REONBOARD", "CLAWMETRY_SKIP_ONBOARD", "CLAWMETRY_API_KEY", "CLAWMETRY_NO_CLOUD"): + env.pop(key, None) + env.update(env_extra or {}) + result = subprocess.run( + [PWSH, "-NoProfile", "-Command", script], + capture_output=True, text=True, env=env, timeout=180, + cwd=str(cwd) if cwd else None, stdin=subprocess.DEVNULL, + ) + assert result.returncode == 0, f"pwsh failed: {result.stdout}\n{result.stderr}" + return result.stdout + + +# ── Static parity guards ──────────────────────────────────────────────────── + + +def test_ps1_has_the_gate_helpers() -> None: + body = _ps1() + for needle in ( + "# >>> CM_EXISTING_SETUP_BLOCK_START", + "# <<< CM_EXISTING_SETUP_BLOCK_END", + "function Get-ClawmetryExistingSetup", + "function Show-ClawmetryExistingSetup", + "function Confirm-ClawmetryReonboard", + "function Invoke-ClawmetryOnboard", + ): + assert needle in body, f"install.ps1 lost {needle!r}" + + +def test_ps1_runs_the_wizard_when_no_account_is_linked() -> None: + """The whole point of the gate is that it only gates CONNECTED nodes.""" + body = _ps1() + tail = body[body.index("if ($env:CLAWMETRY_SKIP_ONBOARD -eq \"1\")") :] + assert "if ($setup.Connected)" in tail + assert tail.count("Invoke-ClawmetryOnboard") >= 2, ( + "both branches must be able to onboard: after a yes, and unconditionally " + "when no account is linked" + ) + + +def test_ps1_upgrades_in_place_instead_of_wiping_the_venv() -> None: + """Windows locks a running process's files: `Remove-Item -Recurse` on the + venv fails on any machine whose daemon is up -- which is exactly the + machine this gate exists for.""" + body = _ps1() + assert "pyvenv.cfg" in body, "install.ps1 must detect a reusable venv" + assert "--upgrade clawmetry" in body, "install.ps1 must upgrade in place" + assert "Removing previous (incomplete) installation" in body, ( + "the wipe must be reserved for a venv that is not usable" + ) + + +def test_ps1_stops_and_restarts_a_running_daemon() -> None: + body = _ps1() + assert "schtasks /end" in body, "a live daemon must be stopped before the upgrade" + assert "schtasks /run" in body, "the daemon must come back on the NEW code" + assert "ClawMetrySyncDaemon" in body + + +def test_cmd_has_the_gate() -> None: + body = _cmd() + assert "CLAWMETRY_SKIP_ONBOARD" in body + assert "CLAWMETRY_REONBOARD" in body + assert ":cm_reonboard" in body and ":cm_keep" in body and ":cm_onboard_done" in body + assert "-m clawmetry onboard" in body, ( + "install.cmd must be able to onboard without depending on the Scripts " + "dir already being on PATH in this session" + ) + assert "clawmetry status --json" in body + + +def test_all_three_installers_share_one_contract() -> None: + """Same env overrides and the same placeholder rule on every platform.""" + sh, ps1, cmd = INSTALL_SH.read_text(), _ps1(), _cmd() + for body, name in ((sh, "install.sh"), (ps1, "install.ps1"), (cmd, "install.cmd")): + assert "CLAWMETRY_REONBOARD" in body, f"{name} missing the re-onboard override" + assert "CLAWMETRY_SKIP_ONBOARD" in body, f"{name} missing the skip override" + assert "@clawmetry.auto" in body, f"{name} must not treat a placeholder as connected" + assert "@clawmetry.linked" in body, f"{name} must not treat a placeholder as connected" + + +# ── PowerShell behaviour ──────────────────────────────────────────────────── + + +def _probe_script(data_dir: Path, exe: Path | str) -> str: + return ( + _helper_block() + + textwrap.dedent( + f""" + $s = Get-ClawmetryExistingSetup -ClawmetryExe '{exe}' -DataDir '{data_dir}' + "connected={{0}}|email={{1}}|plan={{2}}|sync={{3}}|node={{4}}|ver={{5}}" -f ` + $s.Connected, $s.Email, $s.Plan, $s.Sync, $s.Node, $s.Version + """ + ) + ) + + +def _probe(data_dir: Path, exe: Path | str = "no-such-exe") -> dict: + out = _run_pwsh(_probe_script(data_dir, exe)) + line = [ln for ln in out.splitlines() if ln.startswith("connected=")][-1] + return dict(kv.split("=", 1) for kv in line.split("|")) + + +def _stub_cli(directory: Path, snapshot: dict | None, marker: Path | None = None) -> Path: + """A fake clawmetry that answers --version / status --json and records an + onboard invocation. POSIX-only (these tests run under pwsh on Linux/macOS).""" + exe = directory / "clawmetry-stub" + exe.write_text( + "#!/bin/bash\n" + "case \"$1\" in\n" + " --version) echo 'clawmetry 0.12.999' ;;\n" + f" status) cat <<'JSON'\n{json.dumps(snapshot or {})}\nJSON\n ;;\n" + + (f" onboard) echo STUB-ONBOARD-RAN; touch '{marker}' ;;\n" if marker else "") + + "esac\n" + ) + exe.chmod(0o755) + return exe + + +@needs_pwsh +def test_probe_fresh_machine_is_not_connected(tmp_path: Path) -> None: + assert _probe(tmp_path)["connected"] == "False" + + +@needs_pwsh +def test_probe_local_only_without_account_is_not_connected(tmp_path: Path) -> None: + (tmp_path / "config.json").write_text(json.dumps({"api_key": "", "node_id": "box-1", "local_only": True})) + (tmp_path / "nocloud").touch() + vals = _probe(tmp_path) + assert vals["connected"] == "False" + assert vals["sync"] == "local-only" + + +@needs_pwsh +def test_probe_placeholder_account_is_not_connected(tmp_path: Path) -> None: + (tmp_path / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "account_email": "node-77@clawmetry.auto"}) + ) + vals = _probe(tmp_path) + assert vals["connected"] == "False" + assert vals["email"] == "" + + +@needs_pwsh +def test_probe_falls_back_to_config_files(tmp_path: Path) -> None: + (tmp_path / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "node_id": "box-3", "account_email": "a@b.com"}) + ) + (tmp_path / "cloud_plan.json").write_text(json.dumps({"plan": "cloud_starter"})) + vals = _probe(tmp_path) + assert vals["connected"] == "True" + assert vals["email"] == "a@b.com" + assert vals["plan"] == "Starter" + assert vals["sync"] == "cloud" + + +@needs_pwsh +def test_probe_prefers_the_cli_snapshot(tmp_path: Path) -> None: + (tmp_path / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "node_id": "stale", "account_email": "old@b.com"}) + ) + exe = _stub_cli( + tmp_path, + { + "version": "0.12.999", + "cloud_sync": { + "api_key_masked": "cm_abc...6789", + "account": {"email": "founder@example.com", "plan": "cloud_pro", "placeholder": False}, + "node_id": "test-box", + "local_only": True, + }, + }, + ) + vals = _probe(tmp_path, exe) + assert vals["connected"] == "True" + assert vals["email"] == "founder@example.com" + assert vals["plan"] == "Pro" + assert vals["sync"] == "local-only" + assert vals["node"] == "test-box" + assert vals["ver"] == "0.12.999" + + +@needs_pwsh +def test_probe_survives_corrupt_config(tmp_path: Path) -> None: + (tmp_path / "config.json").write_text("not json at all") + assert _probe(tmp_path)["connected"] == "False" + + +@needs_pwsh +def test_gate_keeps_setup_when_not_interactive(tmp_path: Path) -> None: + """No controlling terminal (CI, a provisioning script) must never + re-onboard a machine behind the operator's back.""" + out = _run_pwsh(_helper_block() + "\nif (Confirm-ClawmetryReonboard) { 'RESULT=onboard' } else { 'RESULT=keep' }\n") + assert "RESULT=keep" in out + assert "keeping your current setup" in out.lower() + + +@needs_pwsh +@pytest.mark.parametrize("flag,expected", [("1", "onboard"), ("yes", "onboard"), ("0", "keep"), ("no", "keep")]) +def test_gate_env_override(flag: str, expected: str) -> None: + out = _run_pwsh( + _helper_block() + "\nif (Confirm-ClawmetryReonboard) { 'RESULT=onboard' } else { 'RESULT=keep' }\n", + {"CLAWMETRY_REONBOARD": flag}, + ) + assert f"RESULT={expected}" in out + + +@needs_pwsh +def test_summary_reports_account_plan_sync_and_version(tmp_path: Path) -> None: + exe = _stub_cli( + tmp_path, + { + "version": "0.12.999", + "cloud_sync": { + "api_key_masked": "cm_abc...6789", + "account": {"email": "founder@example.com", "plan": "cloud_pro", "placeholder": False}, + "node_id": "test-box", + "local_only": True, + }, + }, + ) + (tmp_path / "config.json").write_text(json.dumps({"api_key": "cm_abc123456789"})) + script = _helper_block() + textwrap.dedent( + f""" + $s = Get-ClawmetryExistingSetup -ClawmetryExe '{exe}' -DataDir '{tmp_path}' + Show-ClawmetryExistingSetup -Setup $s + """ + ) + out = _run_pwsh(script) + assert "already connected" in out + assert "founder@example.com" in out + assert "Pro plan" in out + assert "Local-only" in out + assert "0.12.999" in out + assert "test-box" in out + + +@needs_pwsh +def test_summary_never_promises_a_dead_dashboard(tmp_path: Path) -> None: + """Port 1 can't be a dashboard: the URL line must not be invented.""" + (tmp_path / "config.json").write_text(json.dumps({"api_key": "cm_abc123456789"})) + (tmp_path / "server.json").write_text(json.dumps({"port": 1})) + script = _helper_block() + f"\n'DASH=' + (Get-ClawmetryDashboardUrl -DataDir '{tmp_path}')\n" + out = _run_pwsh(script) + dash = [ln for ln in out.splitlines() if ln.startswith("DASH=")][-1] + assert dash in ("DASH=", "DASH=http://localhost:8900"), dash + + +@needs_pwsh +def test_ps1_parses_cleanly() -> None: + out = _run_pwsh( + "$errors = $null; $tokens = $null; " + f"[System.Management.Automation.Language.Parser]::ParseFile('{INSTALL_PS1}', [ref]$tokens, [ref]$errors) | Out-Null; " + "if ($errors) { $errors | ForEach-Object { $_.Message } } else { 'PARSE OK' }" + ) + assert "PARSE OK" in out, out + + +# ── The CMD probe is plain Python: run it directly, on any OS ─────────────── + + +def _cmd_probe_source() -> str: + """Pull the one-liner install.cmd hands to `python -c` out of the script.""" + for line in _cmd().splitlines(): + stripped = line.strip() + # install.cmd also runs a short `python -c` for the version check -- + # the probe is the one that reads the account state. + if stripped.startswith("%PYTHON% -c ") and "cloud_sync" in stripped: + body = stripped[len("%PYTHON% -c ") :] + assert body.startswith('"'), body[:40] + end = body.rindex('"') + return body[1:end] + raise AssertionError("install.cmd no longer has a python probe one-liner") + + +def _run_cmd_probe(home: Path, status: dict | None = None) -> tuple[int, str]: + env = dict(os.environ, HOME=str(home), USERPROFILE=str(home)) + for key in ("CLAWMETRY_API_KEY", "CLAWMETRY_NO_CLOUD"): + env.pop(key, None) + if status is not None: + status_file = home / "status.json" + status_file.write_text(json.dumps(status)) + env["CM_STATUS_FILE"] = str(status_file) + else: + env["CM_STATUS_FILE"] = str(home / "missing.json") + import sys as _sys + + proc = subprocess.run( + [_sys.executable, "-c", _cmd_probe_source()], + capture_output=True, text=True, env=env, timeout=60, + ) + return proc.returncode, proc.stdout + + +def _cm_data(home: Path) -> Path: + d = home / ".clawmetry" + d.mkdir(parents=True, exist_ok=True) + return d + + +def test_cmd_probe_reports_not_connected_on_a_fresh_machine(tmp_path: Path) -> None: + _cm_data(tmp_path) + rc, out = _run_cmd_probe(tmp_path) + assert rc == 1, "exit code carries the answer: non-zero means run the wizard" + assert out.strip() == "" + + +def test_cmd_probe_reports_a_connected_node(tmp_path: Path) -> None: + data = _cm_data(tmp_path) + (data / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "node_id": "box-9", "account_email": "a@b.com"}) + ) + (data / "cloud_plan.json").write_text(json.dumps({"plan": "cloud_pro"})) + (data / "nocloud").touch() + rc, out = _run_cmd_probe(tmp_path) + assert rc == 0 + assert "already connected" in out + assert "a@b.com" in out + assert "Pro plan" in out + assert "Local-only" in out + assert "box-9" in out + + +def test_cmd_probe_prefers_the_status_snapshot(tmp_path: Path) -> None: + data = _cm_data(tmp_path) + (data / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "node_id": "stale", "account_email": "old@b.com"}) + ) + rc, out = _run_cmd_probe( + tmp_path, + { + "version": "0.12.999", + "cloud_sync": { + "api_key_masked": "cm_abc...6789", + "account": {"email": "founder@example.com", "plan": "cloud_starter", "placeholder": False}, + "node_id": "test-box", + "local_only": False, + }, + }, + ) + assert rc == 0 + assert "founder@example.com" in out + assert "Starter plan" in out + assert "app.clawmetry.com" in out + assert "0.12.999" in out + + +def test_cmd_probe_rejects_a_placeholder_account(tmp_path: Path) -> None: + data = _cm_data(tmp_path) + (data / "config.json").write_text( + json.dumps({"api_key": "cm_abc123456789", "account_email": "node-77@clawmetry.auto"}) + ) + rc, out = _run_cmd_probe(tmp_path) + assert rc == 1 + assert out.strip() == "" + + +def test_cmd_probe_degrades_to_not_connected_on_a_corrupt_config(tmp_path: Path) -> None: + """A crash here must read as "no account" (wizard runs), never as a + half-rendered summary or a failed install.""" + data = _cm_data(tmp_path) + (data / "config.json").write_text("not json at all") + rc, out = _run_cmd_probe(tmp_path) + assert rc != 0 + assert "already connected" not in out From 464d3fbff2cdc41c7d0250233d46f693647b7e47 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 18 Aug 2026 17:32:05 +0200 Subject: [PATCH 2/4] Say "keeping your current setup" on a forced skip too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAWMETRY_REONBOARD=0 returned from the gate without a word, so an operator who forced the skip saw the summary and then nothing — indistinguishable from the installer ignoring the variable. Caught by the new windows-latest CI gate step, which asserts the message on exactly that path. Both install.sh and install.ps1 now print the same "Keeping your current setup" + "Change it anytime: clawmetry onboard" lines they print for an interactive "no"; install.cmd already routed the forced skip through its :cm_keep label. Co-Authored-By: Claude Opus 5 --- install.ps1 | 6 +++++- install.sh | 7 ++++++- tests/test_install_script_existing_account_gate.py | 10 ++++++++++ tests/test_windows_installer_existing_account_gate.py | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 736a9ccd67..5b4589fae1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -386,7 +386,11 @@ function Confirm-ClawmetryReonboard { if ($env:CLAWMETRY_REONBOARD) { $flag = $env:CLAWMETRY_REONBOARD.ToLowerInvariant() if (@("1", "true", "yes", "on") -contains $flag) { return $true } - if (@("0", "false", "no", "off") -contains $flag) { return $false } + if (@("0", "false", "no", "off") -contains $flag) { + Write-Host " Keeping your current setup." + Write-Host " ↻ Change it anytime: clawmetry onboard" + return $false + } } $interactive = $true try { $interactive = (-not [Console]::IsInputRedirected) } catch {} diff --git a/install.sh b/install.sh index c532b06f4b..5ae029ea2a 100644 --- a/install.sh +++ b/install.sh @@ -409,7 +409,12 @@ _cm_run_onboard() { _cm_reonboard_gate() { case "${CLAWMETRY_REONBOARD:-}" in 1|true|yes|on|TRUE|YES|ON) return 0 ;; - 0|false|no|off|FALSE|NO|OFF) return 1 ;; + 0|false|no|off|FALSE|NO|OFF) + echo -e " ${DIM}Keeping your current setup.${NC}" + echo -e " ${DIM}↻ Change it anytime:${NC} ${GREEN}clawmetry onboard${NC}" + CM_HINTED=1 + return 1 + ;; esac if ! (exec /dev/null; then echo -e " ${DIM}Non-interactive install: keeping your current setup.${NC}" diff --git a/tests/test_install_script_existing_account_gate.py b/tests/test_install_script_existing_account_gate.py index 01897f2396..05d150d0d9 100644 --- a/tests/test_install_script_existing_account_gate.py +++ b/tests/test_install_script_existing_account_gate.py @@ -356,6 +356,16 @@ def test_env_override_forces_reonboard(tmp_path: Path) -> None: assert (home / ".clawmetry" / "onboard.ran").exists() +@posix_only +def test_env_override_skip_says_so(tmp_path: Path) -> None: + """A forced skip keeps the setup AND says it kept it -- silence reads as + "the installer ignored me".""" + home = _connected_home(tmp_path) + transcript = _run_decision_block(home, None, {"CLAWMETRY_REONBOARD": "0"}) + assert not (home / ".clawmetry" / "onboard.ran").exists() + assert "Keeping your current setup" in transcript + + @posix_only def test_unconnected_node_runs_wizard_without_prompting(tmp_path: Path) -> None: """No account linked: unchanged behaviour -- onboard runs, no question.""" diff --git a/tests/test_windows_installer_existing_account_gate.py b/tests/test_windows_installer_existing_account_gate.py index abde031896..876e81397f 100644 --- a/tests/test_windows_installer_existing_account_gate.py +++ b/tests/test_windows_installer_existing_account_gate.py @@ -263,6 +263,10 @@ def test_gate_env_override(flag: str, expected: str) -> None: {"CLAWMETRY_REONBOARD": flag}, ) assert f"RESULT={expected}" in out + if expected == "keep": + # Keeping a setup is never silent -- an operator who forced the skip + # still needs to see that nothing changed and how to change it. + assert "Keeping your current setup" in out @needs_pwsh From b439eb770b29f72fa19b44cb1b6cd8fb2399549a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:30:41 +0000 Subject: [PATCH 3/4] fix(installer): apply existing-account gate at already-up-to-date early exit Both install.ps1 and install.cmd applied the existing-account gate only on the post-install path. The blueprint also requires it at the early-exit point where the installer detects the installed version equals the latest PyPI release and skips the pip upgrade. - install.ps1: version-check block before pip install; reuses the inline Python account probe (helper fns defined later can't be called here). - install.cmd: same logic using flat goto labels (batch can't use labels inside compound if blocks); uses importlib.metadata for reliable version extraction without needing to parse "clawmetry X.Y.Z" output. Fixes drift-bot findings on #4997. Co-Authored-By: Claude Code --- install.cmd | 38 ++++++++++++++++++++++++++++++++++++++ install.ps1 | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/install.cmd b/install.cmd index cd4bd10d4e..b90787159d 100644 --- a/install.cmd +++ b/install.cmd @@ -1,4 +1,5 @@ @echo off +setlocal enabledelayedexpansion REM ClawMetry Installer for Windows (CMD) REM Usage: curl -fsSL https://clawmetry.com/install.cmd -o install.cmd && install.cmd && del install.cmd @@ -60,6 +61,43 @@ goto :eof :cm_sweep_done +REM ── Early exit: already up to date ────────────────────────────────────── +REM Mirror of install.sh: if the installed version equals the latest PyPI +REM release, skip the install but still apply the existing-account gate. +set "CM_CUR_VER=" +set "CM_LAT_VER=" +for /f "delims=" %%V in ('%PYTHON% -c "try:import importlib.metadata;print(importlib.metadata.version(\"clawmetry\"))" 2^>nul') do set "CM_CUR_VER=%%V" +if "!CM_CUR_VER!"=="" goto :cm_do_install +for /f "delims=" %%L in ('%PYTHON% -c "import json,urllib.request;print(json.loads(urllib.request.urlopen(\"https://pypi.org/pypi/clawmetry/json\",timeout=2).read())[\"info\"][\"version\"])" 2^>nul') do set "CM_LAT_VER=%%L" +if not "!CM_CUR_VER!"=="!CM_LAT_VER!" goto :cm_do_install +echo. +echo ✓ ClawMetry !CM_CUR_VER! already up to date +echo. +if "%CLAWMETRY_SKIP_ONBOARD%"=="1" goto :eof +set "CM_STATUS_FILE=%TEMP%\clawmetry-status-early.json" +%PYTHON% -m clawmetry status --json > "%CM_STATUS_FILE%" 2>nul +%PYTHON% -c "import json,os;h=os.path.expanduser('~');d=os.path.join(h,'.clawmetry');g=lambda p:(json.loads(open(p).read()) if p and os.path.exists(p) else {});c=g(os.path.join(d,'config.json'));s=g(os.environ.get('CM_STATUS_FILE',''));cl=s.get('cloud_sync') or {};a=cl.get('account') or {};k=str(c.get('api_key') or '') or os.environ.get('CLAWMETRY_API_KEY','');e=str(a.get('email') or c.get('account_email') or '');ph=bool(a.get('placeholder')) or e.lower().endswith(('@clawmetry.auto','@clawmetry.linked'));conn=(bool(k) or bool(cl.get('api_key_masked'))) and not ph;raise SystemExit(0 if conn else 1)" 2>nul +set "CM_EARLY_PROBE_RC=!ERRORLEVEL!" +del "%CM_STATUS_FILE%" >nul 2>&1 +set "CM_STATUS_FILE=" +if not "!CM_EARLY_PROBE_RC!"=="0" goto :eof +if /I "%CLAWMETRY_REONBOARD%"=="1" goto :cm_early_reonboard +if /I "%CLAWMETRY_REONBOARD%"=="0" goto :cm_early_keep +set "CM_EARLY_ANS=" +set /p "CM_EARLY_ANS= Re-run setup [account, cloud vs local-only, license]? [y/N]: " +if /I "!CM_EARLY_ANS!"=="y" goto :cm_early_reonboard +if /I "!CM_EARLY_ANS!"=="yes" goto :cm_early_reonboard +:cm_early_keep +echo. +echo Keeping your current setup. +echo Change it anytime: clawmetry onboard +goto :eof +:cm_early_reonboard +%PYTHON% -m clawmetry onboard +goto :eof + +:cm_do_install + echo → Installing clawmetry... %PYTHON% -m pip install --upgrade clawmetry >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( diff --git a/install.ps1 b/install.ps1 index 5b4589fae1..871160a930 100644 --- a/install.ps1 +++ b/install.ps1 @@ -154,6 +154,53 @@ if ((Test-Path $venvPython) -and (Test-Path "$installDir\pyvenv.cfg")) { } } +# ── Early exit: already up to date ───────────────────────────────────────── +# Mirror of install.sh's early-exit section: if the installed version already +# equals the latest PyPI release, nothing needs to be installed. We still apply +# the existing-account gate so a re-run on an already-connected machine reports +# the current setup and offers the wizard -- same contract as the post-install +# path below. +$_binDir0 = "$installDir\Scripts" +$_cmExe0 = "$_binDir0\clawmetry.exe" +if ((Test-Path $_cmExe0) -and (Test-Path $venvPython)) { + $_curVer = "" + try { $_curVer = (((& $_cmExe0 --version 2>$null) | Out-String) -replace '.*?(\d+\.\d+\.\d+).*','$1').Trim() } catch {} + $_latVer = "" + try { + $_latVer = ((& $venvPython -c "import json,urllib.request;print(json.loads(urllib.request.urlopen('https://pypi.org/pypi/clawmetry/json',timeout=2).read())['info']['version'])" 2>$null) | Out-String).Trim() + } catch {} + if ($_curVer -and $_latVer -and ($_curVer -eq $_latVer)) { + Write-Host "" + Write-Host " ✓ ClawMetry $_curVer already up to date" -ForegroundColor Green + Write-Host "" + if ($env:CLAWMETRY_SKIP_ONBOARD -ne "1") { + $_conn = "0" + try { + $_conn = ((& $venvPython -c "import json,os;h=os.path.expanduser('~');d=os.path.join(h,'.clawmetry');g=lambda p:(json.loads(open(p).read()) if p and os.path.exists(p) else {});c=g(os.path.join(d,'config.json'));k=str(c.get('api_key') or '') or os.environ.get('CLAWMETRY_API_KEY','');e=str(c.get('account_email') or '');ph=e.lower().endswith(('@clawmetry.auto','@clawmetry.linked'));print('1' if (bool(k) and not ph) else '0')" 2>$null) | Out-String).Trim() + } catch {} + if ($_conn -eq "1") { + if ($env:CLAWMETRY_REONBOARD) { + $flag = $env:CLAWMETRY_REONBOARD.ToLowerInvariant() + if (@("1","true","yes","on") -contains $flag) { try { & $_cmExe0 onboard } catch {} } + else { Write-Host " Keeping your current setup."; Write-Host " ↻ Change it anytime: clawmetry onboard" } + } else { + $_isInteractive = $true + try { $_isInteractive = (-not [Console]::IsInputRedirected) } catch {} + if ($_isInteractive) { + $_ans = ""; try { $_ans = Read-Host " Re-run setup (account, cloud vs local-only, license)? [y/N]" } catch {} + if (@("y","yes") -contains ("$_ans").Trim().ToLowerInvariant()) { try { & $_cmExe0 onboard } catch {} } + else { Write-Host ""; Write-Host " Keeping your current setup."; Write-Host " ↻ Change it anytime: clawmetry onboard" } + } else { + Write-Host " Non-interactive install: keeping your current setup." + Write-Host " ↻ Change it anytime: clawmetry onboard" + } + } + } + } + exit 0 + } +} + # Upgrade pip (using python -m pip to avoid in-use upgrade error on Windows) & $venvPython -m pip install --upgrade pip 2>&1 | Out-Null From b18e60e048726306815d7f24e596bacdd53de399 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 18 Aug 2026 22:14:06 +0200 Subject: [PATCH 4/4] Revert the duplicated early-exit gate on the Windows installers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts b439eb770, which was pushed onto this branch to close a drift-bot finding. Three problems with it: 1. It runs AFTER the pre-flight that stops a live ClawMetry daemon, and returns before the restart block. On the exact machine this feature targets (an already-connected node whose daemon is up) the installer would stop the daemon and exit, leaving the node silently dead. 2. It re-implements the probe and the prompt inline instead of using the helpers a few lines below, so the same contract now had two copies that can drift — and the inline copy ignores the `status --json` snapshot, so it loses the live account email/plan. 3. It prints "already up to date" and the keep/prompt lines WITHOUT the setup summary, which is the whole point of the gate. The windows-latest CI step caught exactly that. The finding it answered was written against blueprint v4. The Installer Scripts blueprint (v5) now scopes that contract correctly: install.sh has two entry points (its early exit skips a full venv rebuild, so it is worth having), while the Windows installers have a single path — the install step is an idempotent `pip install --upgrade` that no-ops when current — and apply the gate once, after it. Co-Authored-By: Claude Opus 5 --- install.cmd | 38 -------------------------------------- install.ps1 | 47 ----------------------------------------------- 2 files changed, 85 deletions(-) diff --git a/install.cmd b/install.cmd index b90787159d..cd4bd10d4e 100644 --- a/install.cmd +++ b/install.cmd @@ -1,5 +1,4 @@ @echo off -setlocal enabledelayedexpansion REM ClawMetry Installer for Windows (CMD) REM Usage: curl -fsSL https://clawmetry.com/install.cmd -o install.cmd && install.cmd && del install.cmd @@ -61,43 +60,6 @@ goto :eof :cm_sweep_done -REM ── Early exit: already up to date ────────────────────────────────────── -REM Mirror of install.sh: if the installed version equals the latest PyPI -REM release, skip the install but still apply the existing-account gate. -set "CM_CUR_VER=" -set "CM_LAT_VER=" -for /f "delims=" %%V in ('%PYTHON% -c "try:import importlib.metadata;print(importlib.metadata.version(\"clawmetry\"))" 2^>nul') do set "CM_CUR_VER=%%V" -if "!CM_CUR_VER!"=="" goto :cm_do_install -for /f "delims=" %%L in ('%PYTHON% -c "import json,urllib.request;print(json.loads(urllib.request.urlopen(\"https://pypi.org/pypi/clawmetry/json\",timeout=2).read())[\"info\"][\"version\"])" 2^>nul') do set "CM_LAT_VER=%%L" -if not "!CM_CUR_VER!"=="!CM_LAT_VER!" goto :cm_do_install -echo. -echo ✓ ClawMetry !CM_CUR_VER! already up to date -echo. -if "%CLAWMETRY_SKIP_ONBOARD%"=="1" goto :eof -set "CM_STATUS_FILE=%TEMP%\clawmetry-status-early.json" -%PYTHON% -m clawmetry status --json > "%CM_STATUS_FILE%" 2>nul -%PYTHON% -c "import json,os;h=os.path.expanduser('~');d=os.path.join(h,'.clawmetry');g=lambda p:(json.loads(open(p).read()) if p and os.path.exists(p) else {});c=g(os.path.join(d,'config.json'));s=g(os.environ.get('CM_STATUS_FILE',''));cl=s.get('cloud_sync') or {};a=cl.get('account') or {};k=str(c.get('api_key') or '') or os.environ.get('CLAWMETRY_API_KEY','');e=str(a.get('email') or c.get('account_email') or '');ph=bool(a.get('placeholder')) or e.lower().endswith(('@clawmetry.auto','@clawmetry.linked'));conn=(bool(k) or bool(cl.get('api_key_masked'))) and not ph;raise SystemExit(0 if conn else 1)" 2>nul -set "CM_EARLY_PROBE_RC=!ERRORLEVEL!" -del "%CM_STATUS_FILE%" >nul 2>&1 -set "CM_STATUS_FILE=" -if not "!CM_EARLY_PROBE_RC!"=="0" goto :eof -if /I "%CLAWMETRY_REONBOARD%"=="1" goto :cm_early_reonboard -if /I "%CLAWMETRY_REONBOARD%"=="0" goto :cm_early_keep -set "CM_EARLY_ANS=" -set /p "CM_EARLY_ANS= Re-run setup [account, cloud vs local-only, license]? [y/N]: " -if /I "!CM_EARLY_ANS!"=="y" goto :cm_early_reonboard -if /I "!CM_EARLY_ANS!"=="yes" goto :cm_early_reonboard -:cm_early_keep -echo. -echo Keeping your current setup. -echo Change it anytime: clawmetry onboard -goto :eof -:cm_early_reonboard -%PYTHON% -m clawmetry onboard -goto :eof - -:cm_do_install - echo → Installing clawmetry... %PYTHON% -m pip install --upgrade clawmetry >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( diff --git a/install.ps1 b/install.ps1 index 871160a930..5b4589fae1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -154,53 +154,6 @@ if ((Test-Path $venvPython) -and (Test-Path "$installDir\pyvenv.cfg")) { } } -# ── Early exit: already up to date ───────────────────────────────────────── -# Mirror of install.sh's early-exit section: if the installed version already -# equals the latest PyPI release, nothing needs to be installed. We still apply -# the existing-account gate so a re-run on an already-connected machine reports -# the current setup and offers the wizard -- same contract as the post-install -# path below. -$_binDir0 = "$installDir\Scripts" -$_cmExe0 = "$_binDir0\clawmetry.exe" -if ((Test-Path $_cmExe0) -and (Test-Path $venvPython)) { - $_curVer = "" - try { $_curVer = (((& $_cmExe0 --version 2>$null) | Out-String) -replace '.*?(\d+\.\d+\.\d+).*','$1').Trim() } catch {} - $_latVer = "" - try { - $_latVer = ((& $venvPython -c "import json,urllib.request;print(json.loads(urllib.request.urlopen('https://pypi.org/pypi/clawmetry/json',timeout=2).read())['info']['version'])" 2>$null) | Out-String).Trim() - } catch {} - if ($_curVer -and $_latVer -and ($_curVer -eq $_latVer)) { - Write-Host "" - Write-Host " ✓ ClawMetry $_curVer already up to date" -ForegroundColor Green - Write-Host "" - if ($env:CLAWMETRY_SKIP_ONBOARD -ne "1") { - $_conn = "0" - try { - $_conn = ((& $venvPython -c "import json,os;h=os.path.expanduser('~');d=os.path.join(h,'.clawmetry');g=lambda p:(json.loads(open(p).read()) if p and os.path.exists(p) else {});c=g(os.path.join(d,'config.json'));k=str(c.get('api_key') or '') or os.environ.get('CLAWMETRY_API_KEY','');e=str(c.get('account_email') or '');ph=e.lower().endswith(('@clawmetry.auto','@clawmetry.linked'));print('1' if (bool(k) and not ph) else '0')" 2>$null) | Out-String).Trim() - } catch {} - if ($_conn -eq "1") { - if ($env:CLAWMETRY_REONBOARD) { - $flag = $env:CLAWMETRY_REONBOARD.ToLowerInvariant() - if (@("1","true","yes","on") -contains $flag) { try { & $_cmExe0 onboard } catch {} } - else { Write-Host " Keeping your current setup."; Write-Host " ↻ Change it anytime: clawmetry onboard" } - } else { - $_isInteractive = $true - try { $_isInteractive = (-not [Console]::IsInputRedirected) } catch {} - if ($_isInteractive) { - $_ans = ""; try { $_ans = Read-Host " Re-run setup (account, cloud vs local-only, license)? [y/N]" } catch {} - if (@("y","yes") -contains ("$_ans").Trim().ToLowerInvariant()) { try { & $_cmExe0 onboard } catch {} } - else { Write-Host ""; Write-Host " Keeping your current setup."; Write-Host " ↻ Change it anytime: clawmetry onboard" } - } else { - Write-Host " Non-interactive install: keeping your current setup." - Write-Host " ↻ Change it anytime: clawmetry onboard" - } - } - } - } - exit 0 - } -} - # Upgrade pip (using python -m pip to avoid in-use upgrade error on Windows) & $venvPython -m pip install --upgrade pip 2>&1 | Out-Null