From a4fa465c50335281b3bb0d4bc8d2826a9fb940aa Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Wed, 5 Aug 2026 15:12:22 -0400 Subject: [PATCH 1/4] fix(install): register the brain at cto and share one setup UI `ade connect` failed on every clean install, on Windows and macOS alike. Both installers registered the machine brain with `ade serve --install-service`, which inherits `ADE_DEFAULT_ROLE`; on a fresh machine that is unset, so the brain came up at role `agent`. `ade connect` runs at `cto`, and an `agent` brain can never serve a `cto` caller. Every other call site already knew this: the desktop app spawns its runtime at `cto` and refuses to attach to a service that is not (localRuntimeConnectionPool.ts:330,2121), and `ade brain start` pins `cto` internally (cli.ts:15816). Both installers now register through `brain start`, including the PowerShell rollback path that was restoring the previous service at `agent` too. The installer was also silent and dishonest: ~30s with no output, raw node:sqlite ExperimentalWarnings as the only proof of life, no progress on a 118 MB runtime or a 1 GB app download, and a cheerful next step printed after sign-in had already failed. The shell scripts now own only what must happen before the `ade` binary exists. Everything after is `ade setup`: one TypeScript implementation both platforms hand off to, so the drift that left macOS with a download progress bar and Windows without one cannot recur. It runs the agent CLIs, account, and desktop app; verifies the install end to end; and prints a summary where a failed step names the command that fixes it. - account: confirms an existing link (keep/switch/skip) instead of re-prompting blind - desktop: skips the ~1 GB download when that version is installed, resumes a partial download via Range, verifies base64 SHA-512 - desktop launch is detached, so a Windows GUI child no longer inherits the console and sprays Electron logs over the user's prompt - node:sqlite ExperimentalWarning filtered at CLI entry; every other warning class still prints - rendering degrades to plain appended lines on legacy conhost, pipes, and CI Reuses the existing step/summary model from commands/connect.ts, byte progress from commands/tools.ts, readInstalledDesktopVersion from commands/doctor.ts, and releaseAssetUrl from lib/releaseAssets.ts. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/README.md | 10 +- apps/ade-cli/scripts/install-runtime.ps1 | 337 ++++++----- apps/ade-cli/scripts/install-runtime.sh | 291 ++++----- apps/ade-cli/src/cli.ts | 137 ++++- apps/ade-cli/src/commands/setup.test.ts | 548 +++++++++++++++++ apps/ade-cli/src/commands/setup.ts | 551 ++++++++++++++++++ apps/ade-cli/src/commands/setupDesktop.ts | 304 ++++++++++ apps/ade-cli/src/commands/setupRender.ts | 356 +++++++++++ apps/ade-cli/src/lib/nodeWarnings.test.ts | 44 ++ apps/ade-cli/src/lib/nodeWarnings.ts | 89 +++ .../onboarding-and-settings/README.md | 26 +- 11 files changed, 2349 insertions(+), 344 deletions(-) create mode 100644 apps/ade-cli/src/commands/setup.test.ts create mode 100644 apps/ade-cli/src/commands/setup.ts create mode 100644 apps/ade-cli/src/commands/setupDesktop.ts create mode 100644 apps/ade-cli/src/commands/setupRender.ts create mode 100644 apps/ade-cli/src/lib/nodeWarnings.test.ts create mode 100644 apps/ade-cli/src/lib/nodeWarnings.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 553c2c1c3..1234af986 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -67,11 +67,15 @@ Three ways to put `ade` on a machine: - `ADE_INSTALL_NO_PROMPT=1` — skip the interactive sign-in and desktop-app offers. - `ADE_INSTALL_NO_PATH=1` — write `$ADE_HOME/env` but never touch a shell profile (the POSIX equivalent of `-NoPath`). - After a successful install both scripts run `ade tools ensure` so the pinned agent CLIs (Codex, Claude Code, OpenCode) are in the shared machine cache before the first agent run rather than as a surprise multi-hundred-megabyte download. That step is non-fatal — the brain retries it in the background on every `ade serve`. Then both scripts offer to run `ade connect`, which links the machine to your ADE account, and then offer the desktop app (macOS `.zip` via `ditto`, Windows NSIS installer via a silent per-user `/S` run). Both desktop downloads are verified against the base64 SHA-512 in the electron-updater manifest (`latest-mac.yml` / `latest.yml`) — the published `SHA256SUMS` covers only the standalone runtime assets. Prompts are read from `/dev/tty` on POSIX because `curl | sh` occupies stdin; when no terminal is attached (CI, automation) both scripts skip the interactive steps and print the follow-up commands instead. `install.ps1` also accepts `-NoPrompt`. + After a successful install both scripts hand off to `ade setup`, passing what they already did (`--continue --runtime-path … --native-path … --elapsed-ms … --downloaded-bytes …`) so the closing summary can recap all five steps. The shell owns only the two steps that must happen before the `ade` binary exists; everything after is one TypeScript implementation shared by both platforms, which is what stops Windows and macOS drifting apart the way they had. + + `ade setup` runs the pinned agent CLIs (Codex, Claude Code, OpenCode) into the shared machine cache so the first agent run is not a surprise multi-hundred-megabyte download — non-fatal, since the brain retries it on every `ade serve`. It then handles the account: an already-linked machine is offered keep / switch / skip instead of being asked to sign in blind. Finally the desktop app (macOS `.zip` via `ditto`, Windows NSIS installer via a silent per-user `/S` run), verified against the base64 SHA-512 in the electron-updater manifest (`latest-mac.yml` / `latest.yml`) — the published `SHA256SUMS` covers only the standalone runtime assets. That download resumes from a partial file via a Range request, is skipped entirely when the same version is already installed, and the installed app is launched detached so a Windows GUI child never inherits the console. + + The run ends with an end-to-end verification (brain reachable, machine linked) and a summary. A failed step reports the command that fixes it inline and again under "What's left"; a clean run prints neither, so the installer never claims success it did not achieve. Prompts are read from `/dev/tty` on POSIX because `curl | sh` occupies stdin; when no terminal is attached (CI, automation) both scripts pass `--no-prompt` and `ade setup` prints the follow-up commands instead. `install.ps1` also accepts `-NoPrompt`. Re-run `ade setup` at any time to redo the flow. For an unpublished Windows proof bundle, run `install.ps1 -AssetDirectory ` (or set `ADE_RELEASE_ASSET_DIR`) to install the local checksum, executable, and native archive without creating a GitHub Release. - The POSIX script downloads `ade-` to `$ADE_INSTALL_DIR/ade`; the PowerShell script downloads `ade-win32-x64.exe` to `$ADE_INSTALL_DIR\ade.exe`. Both verify the binary and matching `.native.tar.gz` against `SHA256SUMS`, extract native dependencies under `$ADE_HOME/runtime//`, run `ade --version`, and register the per-user login service. Both put `ade` on `PATH`. The PowerShell installer adds the install directory to the current user's `PATH` (idempotently, then broadcasts `WM_SETTINGCHANGE`) and tells you to open a new terminal. The POSIX installer writes `$ADE_HOME/env` — a guarded `case ":${PATH}:" in ... esac` prepend that is safe to source repeatedly — and, with consent on a tty, appends one marker-commented block (`# >>> ade >>>` / `. "$HOME/.ade/env"` / `# <<< ade <<<`) to `~/.zshrc` (zsh; `~/.zprofile` only when no `~/.zshrc` exists), `~/.bash_profile` (bash on macOS) or `~/.bashrc` (bash on Linux). It greps for the marker first, so re-running the installer — which is also the update path — never duplicates the block. fish and unrecognized shells are never edited: the installer prints `fish_add_path ""` or the source line instead, as it does with no tty or with `ADE_INSTALL_NO_PATH=1`. After a profile edit the closing output tells you to run `. "$HOME/.ade/env"` or open a new terminal. Both accept `-NoPath` / `ADE_INSTALL_NO_PATH=1`; use `-NoService` to skip startup registration. + The POSIX script downloads `ade-` to `$ADE_INSTALL_DIR/ade`; the PowerShell script downloads `ade-win32-x64.exe` to `$ADE_INSTALL_DIR\ade.exe`. Both verify the binary and matching `.native.tar.gz` against `SHA256SUMS`, extract native dependencies under `$ADE_HOME/runtime//`, run `ade --version`, and register the per-user login service via `ade brain start`. That command — not `ade serve --install-service` — is the one that pins the runtime's default role to `cto`, which is what `ade connect` and the desktop app both require; registering through `serve --install-service` inherits an unset `ADE_DEFAULT_ROLE`, lands on `agent`, and makes sign-in fail on every clean install. Both put `ade` on `PATH`. The PowerShell installer adds the install directory to the current user's `PATH` (idempotently, then broadcasts `WM_SETTINGCHANGE`) and tells you to open a new terminal. The POSIX installer writes `$ADE_HOME/env` — a guarded `case ":${PATH}:" in ... esac` prepend that is safe to source repeatedly — and, with consent on a tty, appends one marker-commented block (`# >>> ade >>>` / `. "$HOME/.ade/env"` / `# <<< ade <<<`) to `~/.zshrc` (zsh; `~/.zprofile` only when no `~/.zshrc` exists), `~/.bash_profile` (bash on macOS) or `~/.bashrc` (bash on Linux). It greps for the marker first, so re-running the installer — which is also the update path — never duplicates the block. fish and unrecognized shells are never edited: the installer prints `fish_add_path ""` or the source line instead, as it does with no tty or with `ADE_INSTALL_NO_PATH=1`. After a profile edit the closing output tells you to run `. "$HOME/.ade/env"` or open a new terminal. Both accept `-NoPath` / `ADE_INSTALL_NO_PATH=1`; use `-NoService` to skip startup registration. 2. **Desktop bundle** — every packaged ADE.app ships the CLI. macOS path: @@ -403,6 +407,8 @@ ade connect # account + login service + account-di ade connect --status --text # report the three steps without changing anything ade connect --headless # force the copy-paste device flow ade connect --no-login --no-service # opt out of either half +ade setup # finish/redo install setup: agent CLIs, account, desktop app, verification, summary +ade setup --no-desktop --no-prompt # non-interactive; skips the ~1 GB desktop download ade login # loopback OAuth, or device flow on SSH/headless hosts ade login --headless # print verification URL + user code ade auth status --text # account identity + loopback/device/env-token source diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index 73bc05b28..7978fdc97 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -26,7 +26,80 @@ function Resolve-AssetUrl([string]$Name) { return "https://github.com/$Repo/releases/download/$Version/$Name" } -function Download-Asset([string]$Name, [string]$Destination) { +# Only these first two steps live in PowerShell -- everything after the runtime +# exists on disk is handed to `ade setup`, which renders the remaining steps and +# the summary once, in TypeScript, for both platforms. +$script:StepAnsi = $false +try { + # IsErrorRedirected, not IsOutputRedirected: the progress line and step lines + # are written to [Console]::Error, so stderr is the stream that decides + # whether an in-place redraw is safe. + $script:StepAnsi = [Console]::IsErrorRedirected -eq $false -and $Host.UI.SupportsVirtualTerminal +} catch { + $script:StepAnsi = $false +} +$script:DownloadedBytes = 0 +$script:ActiveLine = $false +# Started here so the summary's elapsed time covers the whole install, not just +# the `ade setup` half that renders it. +$script:InstallStopwatch = [Diagnostics.Stopwatch]::StartNew() + +function Format-AdeBytes([double]$Bytes) { + if ($Bytes -lt 1MB) { return "{0:N0} KB" -f ($Bytes / 1KB) } + if ($Bytes -lt 1GB) { return "{0:N1} MB" -f ($Bytes / 1MB) } + return "{0:N1} GB" -f ($Bytes / 1GB) +} + +function Write-AdeBanner { + Write-Host "" + Write-Host " _ ____ _____" + Write-Host " / \ | _ \| ____|" + Write-Host " / _ \ | | | | _|" + Write-Host " / ___ \| |_| | |___" + Write-Host " /_/ \_\____/|_____|" + Write-Host "" +} + +# Clears the in-place progress line before any static output, so a completed +# step never prints on top of a half-drawn bar. +function Clear-AdeActiveLine { + if (-not $script:ActiveLine) { return } + $script:ActiveLine = $false + if ($script:StepAnsi) { + [Console]::Error.Write("`r" + (" " * 78) + "`r") + } +} + +function Write-AdeStep([string]$Symbol, [string]$Label, [string]$Detail) { + Clear-AdeActiveLine + [Console]::Error.WriteLine((" {0} {1} {2}" -f $Symbol, $Label.PadRight(20), $Detail).TrimEnd()) +} + +function Write-AdeProgress([string]$Label, [double]$Received, [double]$Total) { + if (-not $script:StepAnsi) { return } + if ($Total -gt 0) { + $fraction = [Math]::Max(0.0, [Math]::Min(1.0, $Received / $Total)) + $filled = [int][Math]::Round($fraction * 12) + $bar = ("#" * $filled) + ("." * (12 - $filled)) + $line = " {0} {1,3}% {2} - {3}/{4}" -f $bar, [int]($fraction * 100), $Label, + (Format-AdeBytes $Received), (Format-AdeBytes $Total) + } else { + $line = " > {0} - {1}" -f $Label, (Format-AdeBytes $Received) + } + if ($line.Length -gt 78) { $line = $line.Substring(0, 78) } + [Console]::Error.Write("`r" + $line.PadRight(78)) + $script:ActiveLine = $true +} + +# Streams the body so a 118 MB download reports bytes instead of sitting silent. +# `Invoke-WebRequest` cannot do this here: the script sets +# $ProgressPreference = SilentlyContinue (required, or its own progress bar +# corrupts the console under `irm | iex`), which also suppresses any feedback. +function Download-Asset( + [string]$Name, + [string]$Destination, + [string]$ProgressLabel = "" +) { if (-not [string]::IsNullOrWhiteSpace($AssetDirectory)) { $source = Join-Path ([IO.Path]::GetFullPath($AssetDirectory)) $Name if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { @@ -39,7 +112,51 @@ function Download-Asset([string]$Name, [string]$Destination) { if (-not $url.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { Fail "refusing non-HTTPS runtime asset URL: $url" } - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $Destination + if ([string]::IsNullOrWhiteSpace($ProgressLabel)) { + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $Destination + return + } + + Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue + $client = [Net.Http.HttpClient]::new() + try { + $client.Timeout = [TimeSpan]::FromMinutes(30) + $response = $client.GetAsync($url, [Net.Http.HttpCompletionOption]::ResponseHeadersRead). + GetAwaiter().GetResult() + try { + if (-not $response.IsSuccessStatusCode) { + Fail "download failed for $Name (HTTP $([int]$response.StatusCode))" + } + $total = if ($response.Content.Headers.ContentLength) { + [double]$response.Content.Headers.ContentLength + } else { 0 } + $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $output = [IO.File]::Create($Destination) + try { + $buffer = [byte[]]::new(1MB) + $received = 0.0 + $lastReport = [Environment]::TickCount + while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { + $output.Write($buffer, 0, $read) + $received += $read + # Throttled: redrawing on every 1 MB chunk costs more than the socket. + if (([Environment]::TickCount - $lastReport) -ge 100) { + $lastReport = [Environment]::TickCount + Write-AdeProgress $ProgressLabel $received $total + } + } + Write-AdeProgress $ProgressLabel $received $total + $script:DownloadedBytes += $received + } finally { + $output.Dispose() + $input.Dispose() + } + } finally { + $response.Dispose() + } + } finally { + $client.Dispose() + } } function Read-Checksum([string]$ManifestPath, [string]$AssetName) { @@ -82,50 +199,10 @@ function Test-AdeInteractive { return $true } -function Read-AdeConfirmation([string]$Question, [bool]$DefaultYes) { - $hint = if ($DefaultYes) { "[Y/n]" } else { "[y/N]" } - $reply = Read-Host "$Question $hint" - if ([string]::IsNullOrWhiteSpace($reply)) { return $DefaultYes } - switch -Regex ($reply.Trim()) { - '^(y|yes)$' { return $true } - '^(n|no)$' { return $false } - default { return $DefaultYes } - } -} - -# The published SHA256SUMS covers only the standalone runtime assets, so the -# desktop installer is verified against the base64 SHA-512 in latest.yml -- the -# same digest electron-updater checks. -function Get-Sha512Base64([string]$Path) { - $sha = [Security.Cryptography.SHA512]::Create() - try { - $stream = [IO.File]::OpenRead($Path) - try { - return [Convert]::ToBase64String($sha.ComputeHash($stream)) - } finally { - $stream.Dispose() - } - } finally { - $sha.Dispose() - } -} - -function Get-DesktopInstallerEntry([string]$ManifestPath) { - $url = $null - foreach ($line in Get-Content -LiteralPath $ManifestPath -ErrorAction Stop) { - if ($line -match '^\s*-\s+url:\s*(\S+)\s*$') { - $url = $Matches[1] - continue - } - if ($line -match '^\s*sha512:\s*(\S+)\s*$') { - if ($url -and $url.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) { - return [pscustomobject]@{ Name = $url; Sha512 = $Matches[1] } - } - $url = $null - } - } - return $null -} +# Prompting, desktop-installer discovery and its SHA-512 verification all moved +# into `ade setup` (apps/ade-cli/src/commands/setup*.ts) so Windows and macOS +# share one implementation. This script now owns only what has to happen before +# the `ade` binary exists on disk. function Get-ShortSha256([string]$Value) { $sha = [Security.Cryptography.SHA256]::Create() @@ -262,8 +339,11 @@ $installSucceeded = $false try { New-Item -ItemType Directory -Force -Path $tempRoot, $stagedRuntime | Out-Null - Download-Asset $binaryAsset $stagedBinary - Download-Asset $nativeAsset $stagedArchive + Write-AdeBanner + Write-Host " Installing ADE to $AdeHome" + Write-Host "" + Download-Asset $binaryAsset $stagedBinary "ADE runtime" + Download-Asset $nativeAsset $stagedArchive "Native dependencies" Download-Asset "SHA256SUMS" $checksumManifest Verify-Checksum $checksumManifest $binaryAsset $stagedBinary Verify-Checksum $checksumManifest $nativeAsset $stagedArchive @@ -317,13 +397,20 @@ try { & $destinationBinary --version | Out-Null if ($LASTEXITCODE -ne 0) { Fail "installed ADE runtime failed its version check" } if (-not $NoService) { - & $destinationBinary serve --install-service | Out-Null + # `brain start`, NOT `serve --install-service`. The latter registers the + # service at whatever ADE_DEFAULT_ROLE happens to be, and in a fresh install + # that is unset, so the machine brain came up as role `agent`. `ade connect` + # runs at `cto`, and an `agent` brain can never serve a `cto` caller -- so + # sign-in failed on every clean install, on Windows and macOS alike. + # `brain start` pins `cto` internally, matching what the desktop app spawns + # and what it refuses to attach to anything else. + & $destinationBinary brain start | Out-Null if ($LASTEXITCODE -ne 0) { Fail "ADE installed, but its per-user brain service could not be registered" } } if (-not $NoPath) { Install-UserPath $InstallDir } - Write-Output "ADE runtime installed: $destinationBinary" - Write-Output "ADE native runtime: $runtimeDir" + Write-AdeStep "+" "ADE runtime" $destinationBinary + Write-AdeStep "+" "Native dependencies" $runtimeDir $installSucceeded = $true } catch { $installError = $_ @@ -350,7 +437,11 @@ try { if ($previousServiceWasStopped -and (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { try { Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir - & $destinationBinary serve --install-service | Out-Null + # Same `cto` reasoning as the install path above: restoring the previous + # service through `serve --install-service` would put it back at role + # `agent`, leaving the user rolled back onto a brain their own `ade + # connect` cannot talk to. + & $destinationBinary brain start | Out-Null if ($LASTEXITCODE -ne 0) { $rollbackErrors.Add("previous brain service restore exited with code $LASTEXITCODE") } elseif (-not $previousServiceWasRunning) { @@ -375,14 +466,14 @@ try { # --------------------------------------------------------------------------- # Onboarding. Runs only after a fully successful install, and outside the # install's try/catch so nothing here can trigger a rollback of a good install. +# +# Everything past this point -- agent CLIs, account, desktop app, end-to-end +# verification and the closing summary -- is `ade setup`. That is the same +# implementation the macOS installer hands off to, written once in TypeScript +# and unit-tested, so the two platforms cannot drift the way they had (macOS +# had a download progress bar here; Windows silently downloaded a gigabyte). # --------------------------------------------------------------------------- if ($installSucceeded) { - $adeCommand = if ($NoPath) { $destinationBinary } else { "ade" } - # Without a PATH entry the command is an absolute path, which is not - # runnable as-is once it contains a space. Call operator + quotes fixes it. - $adeInvocation = if ($NoPath) { "& `"$destinationBinary`"" } else { "ade" } - $interactive = Test-AdeInteractive - $onboardingTemp = Join-Path ([IO.Path]::GetTempPath()) ("ade-onboard-" + [Guid]::NewGuid().ToString("N")) $onboardingPreviousEnvironment = @{ ADE_HOME = $env:ADE_HOME ADE_PACKAGE_CHANNEL = $env:ADE_PACKAGE_CHANNEL @@ -392,107 +483,55 @@ if ($installSucceeded) { } try { - New-Item -ItemType Directory -Force -Path $onboardingTemp | Out-Null - # `ade connect` needs the runtime sidecar environment; the install's finally - # deliberately restored the caller's copy, so re-apply it just for these - # child processes and put it back afterwards. + # `ade setup` needs the runtime sidecar environment; the install's finally + # deliberately restored the caller's copy, so re-apply it just for this + # child process and put it back afterwards. Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir - # --- agent CLIs --- - # Pinned but not bundled: fetch them into the shared machine cache now so - # the first agent run is not a surprise multi-hundred-megabyte download. - # Non-fatal -- the brain retries in the background on every `ade serve`. - Write-Output "" - Write-Output "Fetching pinned agent CLIs (Codex, Claude Code, OpenCode)..." - try { - & $destinationBinary tools ensure --text - if ($LASTEXITCODE -ne 0) { - Write-Warning "Could not fetch the agent CLIs now; ADE will fetch them on first run." - } - } catch { - Write-Warning "Could not fetch the agent CLIs ($($_.Exception.Message)); ADE will fetch them on first run." - } - - # --- sign in --- - if (-not $interactive) { - Write-Output "" - Write-Output "Next: run '$adeCommand connect' to link this machine to your ADE account." - } else { - Write-Output "" - if (Read-AdeConfirmation "Sign in or create your ADE account to link this machine?" $true) { - try { - & $destinationBinary connect - if ($LASTEXITCODE -ne 0) { - Write-Warning "Sign-in did not finish. Run '$adeCommand connect' to try again." - } - } catch { - Write-Warning "Sign-in did not finish ($($_.Exception.Message)). Run '$adeCommand connect' to try again." - } - } else { - Write-Output "Skipped. Run '$adeCommand connect' later to link this machine." - } - } - - # --- desktop app --- + $runtimeVersion = "" try { - $desktopManifest = Join-Path $onboardingTemp "latest.yml" - Download-Asset "latest.yml" $desktopManifest - $desktopEntry = Get-DesktopInstallerEntry $desktopManifest - if ($null -eq $desktopEntry) { throw "latest.yml did not name a Windows installer" } - - if (-not $interactive) { - Write-Output "Desktop app for Windows: $(Resolve-AssetUrl $desktopEntry.Name)" - } else { - $desktopApp = Join-Path $env:LOCALAPPDATA "Programs\ADE\ADE.exe" - $desktopInstalled = Test-Path -LiteralPath $desktopApp -PathType Leaf - $question = if ($desktopInstalled) { - "Reinstall the ADE desktop app for Windows?" - } else { - "Install the ADE desktop app for Windows? (about 1 GB download)" - } - if (Read-AdeConfirmation $question (-not $desktopInstalled)) { - $desktopInstaller = Join-Path $onboardingTemp $desktopEntry.Name - Write-Output "Downloading $($desktopEntry.Name)" - Download-Asset $desktopEntry.Name $desktopInstaller - $actualSha = Get-Sha512Base64 $desktopInstaller - if (-not [string]::Equals($actualSha, $desktopEntry.Sha512, [StringComparison]::Ordinal)) { - throw "checksum mismatch for $($desktopEntry.Name)" - } - # oneClick:false + perMachine:false + allowElevation:false, so /S is a - # silent per-user install that never raises a UAC prompt. - $process = Start-Process -FilePath $desktopInstaller -ArgumentList "/S" -Wait -PassThru - if ($process.ExitCode -ne 0) { - throw "the desktop installer exited with code $($process.ExitCode)" - } - if (Test-Path -LiteralPath $desktopApp -PathType Leaf) { - Write-Output "ADE desktop app installed: $desktopApp" - Start-Process -FilePath $desktopApp | Out-Null - } else { - Write-Output "ADE desktop app installed." - } - } else { - # Only /, /open, /pair, /privacy and /terms are real SPA routes - # (apps/web/src/app/SiteRoutes.tsx); anything else renders NotFound. - # The homepage carries the install modal. - Write-Output "Skipped. Download it later from https://ade-app.dev" - } - } + $runtimeVersion = ((& $destinationBinary --version) | Out-String).Trim() } catch { - Write-Warning "Desktop app step skipped ($($_.Exception.Message)). The ADE runtime is still installed." + $runtimeVersion = "" } - Write-Output "" - # With -NoPath nothing was added to the user PATH, so `ade` resolves only by - # full path and a new terminal buys the user nothing. - if (-not $NoPath) { - Write-Output "Open a new terminal and run: $adeInvocation connect --status --text" - } else { - Write-Output "Done. Try: $adeInvocation connect --status --text" + $setupArgs = @( + "setup", + "--continue", + "--runtime-path", $destinationBinary, + "--native-path", $runtimeDir, + "--elapsed-ms", ([string][int]$script:InstallStopwatch.ElapsedMilliseconds), + "--downloaded-bytes", ([string][int64]$script:DownloadedBytes) + ) + if (-not [string]::IsNullOrWhiteSpace($runtimeVersion)) { + $setupArgs += @("--runtime-version", $runtimeVersion) } + # No console means no prompts: `ade setup` falls through to printing the + # follow-up commands instead of blocking on a read that can never return. + if (-not (Test-AdeInteractive)) { $setupArgs += "--no-prompt" } + + # stdout/stderr are inherited on purpose: the step lines, the prompts and + # the summary are meant for this console. + & $destinationBinary @setupArgs + $setupExit = $LASTEXITCODE + } catch { + Write-Warning "Setup did not finish ($($_.Exception.Message)). Run 'ade setup' to try again." + $setupExit = 1 } finally { foreach ($name in $onboardingPreviousEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $onboardingPreviousEnvironment[$name], "Process") } - Remove-Item -LiteralPath $onboardingTemp -Recurse -Force -ErrorAction SilentlyContinue } + + # Only this script knows whether it edited the user PATH, so the "new + # terminal" note belongs here rather than in the shared summary. With -NoPath + # nothing was added and a new terminal would buy the user nothing. + if (-not $NoPath) { + Write-Host "" + # Single-quoted: a backtick inside a double-quoted PowerShell string is an + # escape character, so "`ade`" would emit a BEL instead of the word. + Write-Host ' ade is on your PATH in new terminals. This one still needs a restart.' + } + + if ($setupExit -ne 0) { exit $setupExit } } diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index a7fcf62c8..9019aa038 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -46,33 +46,48 @@ download() { fi } -# Like download(), but a failure is reported to the caller instead of aborting -# the install. Used only for the optional desktop-app upsell, which must never -# turn a successful runtime install into a failed one. -try_download() { - try_url="$1" - try_out="$2" +# Same as download(), but with a visible progress bar on stderr. The runtime +# binary and its native archive are ~150 MB together, and downloading them +# silently is what made the installer look frozen for the first 30 seconds. +# stdout/stderr still point at the terminal under `curl | sh`. +download_with_progress() { + dl_url="$1" + dl_out="$2" + dl_label="$3" + printf ' %s\n' "$dl_label" >&2 if command -v curl >/dev/null 2>&1; then - curl -fsSL "$try_url" -o "$try_out" 2>/dev/null + curl -fL --progress-bar "$dl_url" -o "$dl_out" elif command -v wget >/dev/null 2>&1; then - wget -q "$try_url" -O "$try_out" 2>/dev/null + wget -q --show-progress "$dl_url" -O "$dl_out" else - return 1 + die "missing curl or wget" fi } -# The desktop app zip is ~1GB, so show a progress bar rather than appearing to -# hang. stdout/stderr still point at the terminal under `curl | sh`. -try_download_progress() { - try_url="$1" - try_out="$2" - if command -v curl >/dev/null 2>&1; then - curl -fL --progress-bar "$try_url" -o "$try_out" - elif command -v wget >/dev/null 2>&1; then - wget -q --show-progress "$try_url" -O "$try_out" - else - return 1 +file_size_bytes() { + if [ ! -f "$1" ]; then + printf '0\n' + return 0 fi + wc -c <"$1" | tr -d ' ' +} + +print_banner() { + cat >&2 <<'BANNER' + + _ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + +BANNER +} + +# Matches the step lines `ade setup` prints for steps 3-5, so the whole install +# reads as one list even though it spans two processes. +print_step() { + printf ' %s %-20s %s\n' "$1" "$2" "$3" >&2 } sha256_file() { @@ -120,7 +135,14 @@ try_install_service() { # Capture $? from the command itself: after a closing `fi` it would report the # compound statement's status (always 0) and the warning would lie. status=0 - "$dest_dir/ade" serve --install-service >"$service_log" 2>&1 || status="$?" + # `brain start`, NOT `serve --install-service`. The latter registers the + # service at whatever ADE_DEFAULT_ROLE happens to be, and on a fresh install + # that is unset, so the machine brain came up as role `agent`. `ade connect` + # runs at `cto`, and an `agent` brain can never serve a `cto` caller -- so + # sign-in failed on every clean install, on macOS exactly as on Windows. + # `brain start` pins `cto` internally, matching what the desktop app spawns + # and what it refuses to attach to anything else. + "$dest_dir/ade" brain start >"$service_log" 2>&1 || status="$?" if [ "$status" -eq 0 ]; then return 0 fi @@ -143,20 +165,8 @@ asset_url() { fi } -sha512_base64_file() { - # electron-updater manifests carry base64 SHA-512, not hex SHA-256, so the - # desktop app is verified against latest-mac.yml rather than SHA256SUMS - # (which only covers the standalone runtime assets). - sha_file="$1" - if command -v openssl >/dev/null 2>&1; then - openssl dgst -sha512 -binary "$sha_file" | openssl base64 -A - elif command -v shasum >/dev/null 2>&1 && command -v xxd >/dev/null 2>&1 && - command -v base64 >/dev/null 2>&1; then - shasum -a 512 "$sha_file" | awk '{ print $1 }' | xxd -r -p | base64 | tr -d '\n' - else - return 1 - fi -} +# The desktop app's SHA-512 verification moved into `ade setup`; SHA256SUMS +# above still covers the standalone runtime assets this script downloads. # Prompts must come from the terminal: under `curl | sh` the script's own stdin # is the download pipe, so reading it would consume the script or see EOF. @@ -402,11 +412,9 @@ need tar need chmod need awk target="$(detect_target)" -# detect_target runs in a command-substitution subshell, so the `cpu`/`platform` -# it assigns are lost here. Re-derive `cpu` in this shell: `desktop_manifest_entry` -# reads it, and under `set -u` an unset `cpu` would abort the whole install at the -# desktop-app upsell. -cpu="${target#*-}" +# `cpu` used to be re-derived here for the desktop-app upsell's manifest match. +# That moved into `ade setup`, which reads the architecture from its own +# process, so nothing in this script needs it any more. binary_name="ade-$target" archive_name="$binary_name.native.tar.gz" dest_dir="$(choose_install_dir)" @@ -416,11 +424,19 @@ trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM mkdir -p "$dest_dir" "$runtime_dir" "$ade_home/bin" -download "$(asset_url "$binary_name")" "$tmp_dir/ade" -download "$(asset_url "$archive_name")" "$tmp_dir/native.tar.gz" +install_started_at="$(date +%s)" +print_banner +printf ' Installing ADE to %s\n\n' "$ade_home" >&2 + +download_with_progress "$(asset_url "$binary_name")" "$tmp_dir/ade" "ADE runtime" +download_with_progress "$(asset_url "$archive_name")" "$tmp_dir/native.tar.gz" \ + "Native dependencies" download "$(asset_url "SHA256SUMS")" "$tmp_dir/SHA256SUMS" verify_asset_checksum "$binary_name" "$tmp_dir/ade" verify_asset_checksum "$archive_name" "$tmp_dir/native.tar.gz" +downloaded_bytes="$(( + $(file_size_bytes "$tmp_dir/ade") + $(file_size_bytes "$tmp_dir/native.tar.gz") +))" chmod 755 "$tmp_dir/ade" cp "$tmp_dir/ade" "$dest_dir/ade" @@ -461,14 +477,11 @@ elif [ "$(uname -s)" = "Darwin" ]; then try_install_service fi -printf 'ADE runtime installed: %s\n' "$dest_dir/ade" -# Commands we print must be runnable *now*, in this shell, so they name the -# binary by path until the install dir is already on PATH here. A profile edit -# made below only affects new shells. -case ":$PATH:" in - *":$dest_dir:"*) ade_cmd="ade" ;; - *) ade_cmd="$dest_dir/ade" ;; -esac +print_step "+" "ADE runtime" "$dest_dir/ade" +print_step "+" "Native dependencies" "$runtime_dir" +# The recovery commands in the closing summary are printed by `ade setup`, which +# resolves its own invocation, so this script no longer needs to work out +# whether `ade` is on PATH yet. # Under `curl | sh` the script's stdin is the download pipe, so every prompt -- # and every command that prompts -- must be wired to /dev/tty instead. With no @@ -479,146 +492,48 @@ if [ "${ADE_INSTALL_NO_PROMPT:-}" != "1" ] && tty_is_usable; then interactive=1 fi -# The agent CLIs (Codex, Claude Code, OpenCode) are pinned but not bundled -- -# the installer fetches them into the shared machine cache so the first agent -# run is not a surprise multi-hundred-megabyte download. Non-fatal by design: -# the brain retries this in the background on every `ade serve`, so a flaky -# network here costs nothing but the wait. -ensure_agent_tools() { - printf '\nFetching pinned agent CLIs (Codex, Claude Code, OpenCode)...\n' - if "$dest_dir/ade" tools ensure --text; then - return 0 - fi - printf 'ade install: could not fetch the agent CLIs now; ADE will fetch them on first run.\n' >&2 - return 0 -} - -offer_sign_in() { - if [ "$interactive" -ne 1 ]; then - printf '\nNext: run `%s connect` to link this machine to your ADE account.\n' "$ade_cmd" - return 0 - fi - - printf '\n' - if ! ask 'Sign in or create your ADE account to link this machine?' y; then - printf 'Skipped. Run `%s connect` later to link this machine.\n' "$ade_cmd" - return 0 - fi - - # `ade connect` inherits the terminal, so its own prompts and the browser / - # device-code flow work normally. - if "$dest_dir/ade" connect &2 - return 0 -} - -# macOS desktop app upsell. The published SHA256SUMS covers only the standalone -# runtime assets, so the app zip is verified against the base64 SHA-512 in -# latest-mac.yml -- the same digest electron-updater checks. -desktop_manifest_entry() { - awk -v arch="$cpu" ' - $1 == "-" && $2 == "url:" { url = $3; next } - $1 == "sha512:" { - if (url != "" && url ~ ("-" arch "\\.zip$")) { print url; print $2; found = 1; exit } - url = "" - next - } - END { if (!found) exit 1 } - ' "$1" -} - -offer_desktop_app() { - [ "$(uname -s)" = "Darwin" ] || return 0 - - desktop_manifest="$tmp_dir/latest-mac.yml" - try_download "$(asset_url "latest-mac.yml")" "$desktop_manifest" || return 0 - desktop_entry="$(desktop_manifest_entry "$desktop_manifest")" || return 0 - desktop_zip="$(printf '%s\n' "$desktop_entry" | sed -n 1p)" - desktop_sha="$(printf '%s\n' "$desktop_entry" | sed -n 2p)" - [ -n "$desktop_zip" ] && [ -n "$desktop_sha" ] || return 0 - - if [ "$interactive" -ne 1 ]; then - printf 'Desktop app for macOS: %s\n' "$(asset_url "$desktop_zip")" - return 0 - fi - - apps_dir="/Applications" - [ -w "$apps_dir" ] || apps_dir="$HOME/Applications" - installed_app="$apps_dir/ADE.app" - - printf '\n' - if [ -e "$installed_app" ]; then - if ! ask "Replace the existing ADE desktop app at $installed_app?" n; then - return 0 - fi - elif ! ask 'Install the ADE desktop app for macOS? (about 1 GB download)' y; then - # The site is a single-page app whose only routes are /, /open, /pair, - # /privacy and /terms (apps/web/src/app/SiteRoutes.tsx); every other path - # renders NotFoundPage. The homepage carries the install modal, so link - # there rather than at a /download path that does not exist. - printf 'Skipped. Download it later from https://ade-app.dev\n' - return 0 - fi - - printf 'Downloading %s\n' "$desktop_zip" - if ! try_download_progress "$(asset_url "$desktop_zip")" "$tmp_dir/$desktop_zip"; then - printf 'ade install: could not download the desktop app; the ADE runtime is still installed.\n' >&2 - return 0 - fi - desktop_actual="$(sha512_base64_file "$tmp_dir/$desktop_zip")" || { - printf 'ade install: cannot verify the desktop app on this system; skipping it.\n' >&2 - return 0 - } - if [ "$desktop_actual" != "$desktop_sha" ]; then - printf 'ade install: checksum mismatch for %s; skipping the desktop app.\n' "$desktop_zip" >&2 - return 0 - fi +# PATH first: `ade setup` should run with a sane environment, and the user +# should be asked about their shell profile before they are asked about +# accounts and a 1 GB desktop download. +setup_path - desktop_stage="$tmp_dir/desktop" - rm -rf "$desktop_stage" - mkdir -p "$desktop_stage" - if ! ditto -x -k "$tmp_dir/$desktop_zip" "$desktop_stage" 2>/dev/null; then - printf 'ade install: could not expand the desktop app archive; skipping it.\n' >&2 - return 0 - fi - [ -d "$desktop_stage/ADE.app" ] || { - printf 'ade install: desktop app archive did not contain ADE.app; skipping it.\n' >&2 - return 0 - } - - # Move the existing app aside rather than deleting it first, so a failed - # promotion leaves the user's working app in place. - mkdir -p "$apps_dir" - desktop_backup="$tmp_dir/ADE.app.previous" - rm -rf "$desktop_backup" - if [ -e "$installed_app" ] && ! mv "$installed_app" "$desktop_backup"; then - printf 'ade install: could not replace %s; skipping the desktop app.\n' "$installed_app" >&2 - return 0 - fi - if ! mv "$desktop_stage/ADE.app" "$installed_app"; then - if [ -e "$desktop_backup" ]; then - mv "$desktop_backup" "$installed_app" || true - fi - printf 'ade install: could not install the desktop app into %s.\n' "$apps_dir" >&2 - return 0 +# Everything past this point -- agent CLIs, account, desktop app, end-to-end +# verification and the closing summary -- is `ade setup`. That is the same +# implementation the Windows installer hands off to, written once in TypeScript +# and unit-tested, so the two platforms cannot drift the way they had (this +# script had a desktop download progress bar; the PowerShell one did not). +# +# stdin is wired to /dev/tty because under `curl | sh` this script's own stdin +# is the download pipe: prompts reading it would consume the script or see EOF. +# stdout/stderr stay inherited so the step lines and summary reach the terminal. +# Built with positional parameters rather than a space-joined string: an +# ADE_INSTALL_DIR containing a space would otherwise word-split into two broken +# arguments, and `--runtime-path` is exactly the flag that carries such a path. +set -- setup --continue --runtime-path "$dest_dir/ade" --native-path "$runtime_dir" +if runtime_version="$("$dest_dir/ade" --version 2>/dev/null)"; then + runtime_version="$(printf '%s' "$runtime_version" | tr -d '\r\n')" + if [ -n "$runtime_version" ]; then + set -- "$@" --runtime-version "$runtime_version" fi - rm -rf "$desktop_backup" - printf 'ADE desktop app installed: %s\n' "$installed_app" - open "$installed_app" 2>/dev/null || true - return 0 -} - -# PATH first: the sign-in and agent-CLI steps below should run with a sane -# environment, and the user should be asked about their shell profile before -# they are asked about accounts and a 1 GB desktop download. -setup_path -ensure_agent_tools -offer_sign_in -offer_desktop_app +fi +set -- "$@" --elapsed-ms "$(( ($(date +%s) - install_started_at) * 1000 ))" +set -- "$@" --downloaded-bytes "${downloaded_bytes:-0}" +# No terminal (CI, automation) means no prompts: `ade setup` falls through to +# printing the follow-up commands instead of blocking on an unreadable stdin. +[ "$interactive" -eq 1 ] || set -- "$@" --no-prompt + +setup_status=0 +if [ "$interactive" -eq 1 ]; then + "$dest_dir/ade" "$@" Display help for a command $ ade connect [--status] Link this machine to your ADE account + $ ade setup Finish or redo install setup (agent CLIs, account, desktop app) $ ade login [--headless] [--max-wait ] Sign in to the optional ADE account $ ade logout Sign out of the ADE account $ ade auth status Show ADE account sign-in status @@ -12363,6 +12370,9 @@ function buildCliPlan( if (primary === "tools") { return { kind: "tools", rest: args }; } + if (primary === "setup") { + return { kind: "setup", rest: args }; + } if (primary === "serve") { return { kind: "serve", rest: args }; } @@ -15579,6 +15589,128 @@ async function readBrainSyncStatus( } } +/** + * Account state for `ade setup`, read straight off the machine brain. + * + * Deliberately non-throwing: setup uses this only to decide which prompt to + * show, and an unreachable brain should degrade to "offer sign-in", never + * abort an install that is otherwise fine. + */ +async function readSetupAccountStatus( + options: GlobalOptions, +): Promise<{ signedIn: boolean; identity: string | null }> { + let connection: CliConnection | null = null; + try { + connection = await createConnection( + { ...options, headless: false, role: "cto" }, + { autoRegisterProject: false, machineRuntimeOnly: true }, + ); + const status = unwrapActionEnvelope( + await connection.request("account.call", { action: "status", args: {} }), + ); + if (!isRecord(status)) return { signedIn: false, identity: null }; + return { + signedIn: status.signedIn === true, + identity: asString(status.email) ?? asString(status.name), + }; + } catch { + return { signedIn: false, identity: null }; + } finally { + try { + await connection?.close(); + } catch {} + } +} + +/** + * `ade setup` — the interactive half of installation. + * + * The step orchestration and all rendering live in ./commands/setup; this + * function binds it to the real brain, tool cache and release feed. It runs the + * brain at `cto` for the same reason `ade connect` does: the account actions it + * drives are CTO-only. + */ +async function runSetupCli( + rest: string[], + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + const { SetupUsageError, runSetupCommand } = await import("./commands/setup"); + try { + const result = await runSetupCommand(rest, { + env: process.env, + ensureAgentTools: async (onProgress) => { + const { ensureTools, listPinnedTools } = await import("./services/tools/install"); + const names = listPinnedTools(); + await ensureTools(names, { + onProgress: (progress) => { + onProgress({ + fraction: + typeof progress.receivedBytes === "number" && + typeof progress.totalBytes === "number" && + progress.totalBytes > 0 + ? progress.receivedBytes / progress.totalBytes + : null, + receivedBytes: progress.receivedBytes ?? null, + totalBytes: progress.totalBytes ?? null, + item: progress.phase === "waiting" + ? `${progress.tool} (waiting for another ADE install)` + : progress.tool, + }); + }, + }); + return { ok: true, detail: names.join(", ") }; + }, + getAccountStatus: () => readSetupAccountStatus(options), + // Delegates to the same `ade connect` implementation so the OAuth flow, + // the service step and the machine-directory wait stay in one place. + runConnect: async () => { + const connect = await runConnectCli([], options); + return connect.exitCode === 0 + ? { ok: true, detail: "linked" } + : { ok: false, detail: "sign-in didn't finish", nextAction: "ade connect" }; + }, + readInstalledDesktop: () => readInstalledDesktopVersion(), + // "The files copied" is a different claim from "it works". This is the + // check whose absence let a failed sign-in still print a success line. + verify: async () => { + const { getRuntimeServiceStatus } = await import("./serviceManager"); + const service = getRuntimeServiceStatus(); + if (service.running === false) { + return { + ok: false, + detail: "the ADE brain is not running", + nextAction: "ade brain start", + }; + } + const account = await readSetupAccountStatus(options); + if (!account.signedIn) { + return { + ok: false, + detail: "sign-in didn't finish", + nextAction: "ade connect", + }; + } + return { + ok: true, + detail: `ade ${VERSION}, brain running, signed in as ${account.identity ?? "your account"}`, + }; + }, + }); + return { + // Deliberately empty. `ade setup` is a human-facing flow whose entire + // output is the step list and summary already written to stderr; the + // installer inherits stdio, so echoing the result object here would dump + // a JSON blob onto the user's console right under the summary -- the + // exact kind of noise this command exists to remove. + output: "", + exitCode: result.ok ? 0 : 1, + }; + } catch (error) { + if (error instanceof SetupUsageError) throw new CliUsageError(error.message); + throw error; + } +} + /** * `ade connect` — link this machine to the user's ADE account. * @@ -21052,6 +21184,9 @@ async function runCli( if (plan.kind === "connect") { return await runConnectCli(plan.rest, parsed.options); } + if (plan.kind === "setup") { + return await runSetupCli(plan.rest, parsed.options); + } if (plan.kind === "runtime") { const result = await runRuntimeCommand(plan.rest, parsed.options); return { diff --git a/apps/ade-cli/src/commands/setup.test.ts b/apps/ade-cli/src/commands/setup.test.ts new file mode 100644 index 000000000..1ce0f7f91 --- /dev/null +++ b/apps/ade-cli/src/commands/setup.test.ts @@ -0,0 +1,548 @@ +/** + * One suite for the whole `ade setup` feature — orchestration (./setup), + * terminal rendering (./setupRender), and desktop-app acquisition + * (./setupDesktop). Kept in a single file rather than three siblings because + * `src/commands/` is already over its per-folder test-file budget and these are + * one feature, not three. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { + SetupReporter, + detectTerminalCapabilities, + formatActiveLine, + formatCompletedLine, + renderSummary, + type SetupStep, + type TerminalCapabilities, +} from "./setupRender"; +import { + assetUrl, + downloadWithResume, + parseDesktopManifest, + sha512Base64, +} from "./setupDesktop"; +import { parseSetupArgs, runSetupCommand, type SetupDeps } from "./setup"; + +const scriptsDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../scripts", +); + +const plain: TerminalCapabilities = { + interactive: true, + ansi: true, + color: false, + unicode: false, + columns: 80, +}; + +function step(overrides: Partial = {}): SetupStep { + return { + id: "tools", + label: "Agent CLIs", + state: "ok", + detail: "codex, claude-code", + ...overrides, + }; +} + +function reporter(chunks: string[], overrides: Partial = {}) { + return new SetupReporter((text) => chunks.push(text), { + ...plain, + ansi: false, + ...overrides, + }); +} + +function deps(overrides: Partial = {}): SetupDeps { + return { + platform: "linux", + env: {}, + now: () => 0, + ask: async () => 0, + ensureAgentTools: async () => ({ ok: true, detail: "codex, claude-code" }), + getAccountStatus: async () => ({ signedIn: true, identity: "arul@example.com" }), + runConnect: async () => ({ ok: true, detail: "linked" }), + readInstalledDesktop: () => ({ version: null, path: null }), + verify: async () => ({ ok: true, detail: "brain running" }), + ...overrides, + }; +} + +function tempFile(contents: Buffer | string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); + const file = path.join(dir, "artifact.bin"); + fs.writeFileSync(file, contents); + return file; +} + +// --------------------------------------------------------------------------- +// Terminal capabilities + rendering +// --------------------------------------------------------------------------- + +describe("detectTerminalCapabilities", () => { + it("treats a pipe as non-interactive so nothing tries to redraw a log file", () => { + const caps = detectTerminalCapabilities({ isTTY: false }, {}, "linux"); + expect(caps.ansi).toBe(false); + expect(caps.interactive).toBe(false); + }); + + it("treats CI as a pipe even when it passes isTTY", () => { + expect( + detectTerminalCapabilities({ isTTY: true }, { CI: "true" }, "linux").ansi, + ).toBe(false); + }); + + it("honours NO_COLOR and TERM=dumb", () => { + expect( + detectTerminalCapabilities({ isTTY: true }, { NO_COLOR: "1" }, "linux").color, + ).toBe(false); + expect( + detectTerminalCapabilities({ isTTY: true }, { TERM: "dumb" }, "linux").ansi, + ).toBe(false); + }); + + it("falls back to ASCII on a Windows host that advertises nothing modern", () => { + expect(detectTerminalCapabilities({ isTTY: true }, {}, "win32").unicode).toBe(false); + expect( + detectTerminalCapabilities({ isTTY: true }, { WT_SESSION: "1" }, "win32").unicode, + ).toBe(true); + }); +}); + +describe("setup step lines", () => { + it("shows a bar with byte counts when the total is known", () => { + const line = formatActiveLine( + step({ label: "Desktop app" }), + { fraction: 0.5, receivedBytes: 512 * 1024 * 1024, totalBytes: 1024 * 1024 * 1024 }, + plain, + ); + expect(line).toContain("50%"); + // Sizes over 10 MB drop the decimal; a gigabyte rolls over to GB. + expect(line).toContain("512 MB/1.0 GB"); + }); + + it("never exceeds the terminal width, so one clear-line always erases it", () => { + const line = formatActiveLine( + step({ label: "x".repeat(200) }), + { fraction: 0.5, item: "y".repeat(200) }, + plain, + ); + expect(line.length).toBeLessThan(plain.columns); + }); + + it("puts the fix inline underneath a failure and nowhere else", () => { + const failed = formatCompletedLine( + step({ state: "failed", detail: "couldn't reach the ADE brain", nextAction: "ade connect" }), + plain, + ); + expect(failed).toContain("couldn't reach the ADE brain"); + expect(failed).toContain("fix later with: ade connect"); + expect(formatCompletedLine(step(), plain)).not.toContain("fix later"); + }); +}); + +describe("renderSummary", () => { + const totals = { elapsedMs: 134_000, downloadedBytes: 1_288_490_188 }; + + it("omits 'What's left' entirely on a clean install", () => { + const out = renderSummary([step()], totals, plain); + expect(out).toContain("ADE is ready"); + expect(out).not.toContain("What's left"); + }); + + it("names the failed step and what its recovery command achieves", () => { + const out = renderSummary( + [ + step(), + step({ + id: "account", + label: "Account", + state: "failed", + detail: "sign-in didn't finish", + nextAction: "ade connect", + }), + ], + totals, + plain, + ); + expect(out).toContain("1 step needs you"); + expect(out).toContain("What's left"); + // The command, and what it gets you -- not a second copy of the failure. + expect(out).toContain("ade connect link this machine to your account"); + }); + + it("reports elapsed time and bytes downloaded", () => { + expect(renderSummary([step()], totals, plain)).toContain( + "Installed in 2m 14s, 1.2 GB downloaded", + ); + }); + + it("skips steps that never ran", () => { + const out = renderSummary([step({ state: "pending", label: "Desktop app" })], totals, plain); + expect(out).not.toContain("Desktop app"); + }); +}); + +describe("SetupReporter", () => { + it("appends one line per step and emits no escape codes without ANSI", () => { + const chunks: string[] = []; + const r = reporter(chunks); + const active = step({ state: "active" }); + r.beginStep(active); + r.updateStep(active, { fraction: 0.4 }); + r.completeStep(step()); + + const output = chunks.join(""); + expect(output).not.toContain(String.fromCharCode(27)); + expect(output).not.toContain("\r"); + expect(output).toContain("Agent CLIs..."); + }); + + it("erases the live line before printing anything static", () => { + const chunks: string[] = []; + const r = new SetupReporter((text) => chunks.push(text), plain); + r.beginStep(step({ state: "active" })); + r.line(" prompt goes here"); + // The clear must land between the drawn line and the static one, or the + // prompt renders on top of a half-drawn progress bar. + expect(chunks[chunks.length - 2]).toContain("2K"); + expect(chunks[chunks.length - 1]).toBe(" prompt goes here\n"); + }); +}); + +// --------------------------------------------------------------------------- +// Desktop-app acquisition +// --------------------------------------------------------------------------- + +const WINDOWS_MANIFEST = `version: 1.2.54 +files: + - url: ADE-1.2.54-win-x64.exe.blockmap + sha512: blockmapdigest + size: 1234 + - url: ADE-1.2.54-win-x64.exe + sha512: installerdigest + size: 1084000000 +path: ADE-1.2.54-win-x64.exe +sha512: installerdigest +`; + +const MAC_MANIFEST = `version: 1.2.54 +files: + - url: ADE-1.2.54-x64.zip + sha512: intelzip + - url: ADE-1.2.54-arm64.zip + sha512: armzip +path: ADE-1.2.54-arm64.zip +`; + +describe("parseDesktopManifest", () => { + it("picks the .exe on Windows and ignores the blockmap that precedes it", () => { + expect(parseDesktopManifest(WINDOWS_MANIFEST, "win32")).toEqual({ + name: "ADE-1.2.54-win-x64.exe", + sha512: "installerdigest", + }); + }); + + it("picks the zip matching this Mac's architecture", () => { + expect(parseDesktopManifest(MAC_MANIFEST, "darwin", "arm64")?.name) + .toBe("ADE-1.2.54-arm64.zip"); + expect(parseDesktopManifest(MAC_MANIFEST, "darwin", "x64")?.name) + .toBe("ADE-1.2.54-x64.zip"); + }); + + it("returns null rather than guessing when nothing matches", () => { + expect(parseDesktopManifest("version: 1.0.0\nfiles:\n", "win32")).toBeNull(); + }); +}); + +describe("release asset helpers", () => { + it("delegates to the canonical release-feed URL shape", () => { + expect(assetUrl("latest.yml")).toBe( + "https://github.com/arul28/ADE/releases/latest/download/latest.yml", + ); + expect(assetUrl("latest.yml", "arul28/ADE", "v1.2.54")).toBe( + "https://github.com/arul28/ADE/releases/download/v1.2.54/latest.yml", + ); + }); + + it("produces the base64 SHA-512 electron-updater manifests carry, not hex", () => { + expect(sha512Base64(tempFile(""))).toBe( + "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==", + ); + }); +}); + +describe("downloadWithResume", () => { + function response(body: string, status = 200, headers: Record = {}) { + return new Response(body, { status, headers }); + } + + it("requests only the remainder when a partial file is already on disk", async () => { + const destination = tempFile("HELLO"); + const fetchImpl = vi.fn(async () => + response("WORLD", 206, { "content-length": "5" }) + ) as unknown as typeof fetch; + + const total = await downloadWithResume( + "https://example.invalid/ADE.exe", + destination, + () => {}, + { fetchImpl }, + ); + const init = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(init?.headers?.Range).toBe("bytes=5-"); + expect(total).toBe(10); + expect(fs.readFileSync(destination, "utf8")).toBe("HELLOWORLD"); + }); + + it("restarts cleanly when the server ignores the range request", async () => { + // A 200 answer to a ranged request means the whole body: appending it to the + // partial file would silently corrupt the download. + const destination = tempFile("HELLO"); + const fetchImpl = vi.fn(async () => + response("FULLBODY", 200, { "content-length": "8" }) + ) as unknown as typeof fetch; + + await downloadWithResume("https://example.invalid/ADE.exe", destination, () => {}, { + fetchImpl, + }); + expect(fs.readFileSync(destination, "utf8")).toBe("FULLBODY"); + }); + + it("retries a transient failure instead of failing the step", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); + const destination = path.join(dir, "out.bin"); + let attempts = 0; + const fetchImpl = vi.fn(async () => { + attempts += 1; + if (attempts === 1) throw new Error("ECONNRESET"); + return response("OK", 200, { "content-length": "2" }); + }) as unknown as typeof fetch; + + await downloadWithResume("https://example.invalid/ADE.exe", destination, () => {}, { + fetchImpl, + }); + expect(attempts).toBe(2); + expect(fs.readFileSync(destination, "utf8")).toBe("OK"); + }); + + it("reports progress with a total when content-length is known", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); + const destination = path.join(dir, "out.bin"); + const seen: Array<{ receivedBytes: number; totalBytes: number | null }> = []; + const fetchImpl = vi.fn(async () => + response("ABCDEFGH", 200, { "content-length": "8" }) + ) as unknown as typeof fetch; + + await downloadWithResume( + "https://example.invalid/ADE.exe", + destination, + (progress) => seen.push({ + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + }), + { fetchImpl }, + ); + expect(seen.at(-1)).toEqual({ receivedBytes: 8, totalBytes: 8 }); + }); +}); + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +describe("parseSetupArgs", () => { + it("carries the shell's step 1-2 results so the summary covers all five", () => { + const options = parseSetupArgs( + [ + "--continue", + "--runtime-version", "1.2.54", + "--runtime-path", "/home/a/.ade/bin/ade", + "--native-path", "/home/a/.ade/runtime/darwin-arm64", + "--elapsed-ms", "12000", + "--downloaded-bytes", "1024", + ], + {}, + ); + expect(options.continueFromInstaller).toBe(true); + expect(options.runtimeVersion).toBe("1.2.54"); + expect(options.nativePath).toBe("/home/a/.ade/runtime/darwin-arm64"); + expect(options.elapsedMs).toBe(12_000); + expect(options.downloadedBytes).toBe(1024); + }); + + it("treats ADE_INSTALL_NO_PROMPT=1 the same as --no-prompt", () => { + expect(parseSetupArgs([], { ADE_INSTALL_NO_PROMPT: "1" }).prompt).toBe(false); + expect(parseSetupArgs(["--no-prompt"], {}).prompt).toBe(false); + }); + + it("rejects unknown flags rather than silently ignoring them", () => { + expect(() => parseSetupArgs(["--wat"], {})).toThrow(/Unknown option/); + }); +}); + +describe("runSetupCommand", () => { + it("summarises all five steps, including the two the shell already ran", async () => { + // The shell prints the runtime and native-dependency steps live before this + // process exists; the summary must still recap them or it drops a step the + // user just watched succeed. + const result = await runSetupCommand( + ["--continue", "--no-desktop", "--no-prompt", "--runtime-version", "1.2.54"], + deps({ reporter: reporter([], { interactive: false }) }), + ); + expect(result.steps.map((s) => s.id)).toEqual([ + "runtime", + "native", + "tools", + "account", + "desktop", + ]); + }); + + it("never claims success after a failed sign-in", async () => { + const chunks: string[] = []; + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter(chunks), + getAccountStatus: async () => ({ signedIn: false, identity: null }), + runConnect: async () => ({ ok: false, detail: "sign-in didn't finish" }), + verify: async () => ({ + ok: false, + detail: "sign-in didn't finish", + nextAction: "ade connect", + }), + })); + + expect(result.ok).toBe(false); + expect(result.verified).toBe(false); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("failed"); + expect(account?.nextAction).toBe("ade connect"); + expect(chunks.join("")).toContain("What's left"); + }); + + it("downgrades a step that reported ok when verification disagrees", async () => { + // This is the exact shape of the bug: the sign-in step believed it worked + // and the installer printed a next step, while the machine was not linked. + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + verify: async () => ({ + ok: false, + detail: "the ADE brain is not running", + nextAction: "ade brain start", + }), + })); + + expect(result.ok).toBe(false); + expect(result.steps.find((s) => s.id === "account")?.nextAction).toBe("ade brain start"); + }); + + it("keeps going after the agent CLIs fail -- one bad step must not cost the rest", async () => { + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + ensureAgentTools: async () => { + throw new Error("network unreachable"); + }, + })); + + expect(result.steps.find((s) => s.id === "tools")?.state).toBe("failed"); + expect(result.steps.find((s) => s.id === "account")?.state).toBe("ok"); + }); + + it("confirms an existing account with a numbered menu instead of re-prompting blind", async () => { + // Typed explicitly: a zero-arg arrow loses the args tuple, and this test + // asserts on the choices argument. + const ask = vi.fn(async (_question: string, _choices: readonly string[]) => 0); + const runConnect = vi.fn(async () => ({ ok: true, detail: "linked" })); + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + ask, + runConnect, + })); + + expect(ask).toHaveBeenCalledOnce(); + expect(ask.mock.calls[0]?.[1]).toEqual([ + "Keep this account", + "Sign in as someone else", + "Skip for now", + ]); + // Keeping the existing account must not re-run the whole sign-in flow. + expect(runConnect).not.toHaveBeenCalled(); + expect(result.steps.find((s) => s.id === "account")?.detail).toContain("arul@example.com"); + }); + + it("re-runs sign-in when the user picks a different account", async () => { + const runConnect = vi.fn(async () => ({ ok: true, detail: "linked" })); + await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + ask: async () => 1, + runConnect, + })); + expect(runConnect).toHaveBeenCalledOnce(); + }); + + it("asks nothing and prompts for nothing without a terminal", async () => { + const ask = vi.fn(async (_question: string, _choices: readonly string[]) => 0); + const result = await runSetupCommand(["--continue", "--no-prompt"], deps({ + reporter: reporter([], { interactive: false }), + ask, + getAccountStatus: async () => ({ signedIn: false, identity: null }), + })); + + expect(ask).not.toHaveBeenCalled(); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("skipped"); + expect(account?.nextAction).toBe("ade connect"); + }); + + it("skips the 1 GB download when that exact version is already installed", async () => { + const launchDesktop = vi.fn(); + const fetchImpl = vi.fn(async () => + new Response("version: 1.2.54\nfiles:\n - url: ADE-1.2.54-win-x64.exe\n sha512: abc\n") + ) as unknown as typeof fetch; + + const result = await runSetupCommand(["--continue"], deps({ + platform: "win32", + reporter: reporter([]), + fetchImpl, + launchDesktop, + readInstalledDesktop: () => ({ version: "1.2.54", path: process.execPath }), + })); + + const desktop = result.steps.find((s) => s.id === "desktop"); + expect(desktop?.state).toBe("ok"); + expect(desktop?.detail).toContain("already installed"); + // Manifest only -- the artifact itself was never requested. + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(launchDesktop).toHaveBeenCalledOnce(); + }); +}); + +// The bug that made this whole pass necessary: the installers registered the +// machine brain with no ADE_DEFAULT_ROLE, so it came up as `agent`, and +// `ade connect` (which runs at `cto`) could never be served by it. `brain start` +// pins `cto`; `serve --install-service` inherits whatever is set. +describe("installer brain registration (regression)", () => { + for (const script of ["install-runtime.ps1", "install-runtime.sh"]) { + it(`${script} registers the brain via 'brain start', not 'serve --install-service'`, () => { + const source = fs.readFileSync(path.join(scriptsDir, script), "utf8"); + expect(source).toMatch(/\bbrain start\b/); + // Match the invocation, not the word: both scripts explain this bug in a + // comment, and asserting on the bare string would only find the prose. + expect(source).not.toMatch( + /(?:\$destinationBinary|\$dest_dir\/ade")\s+serve\s+--install-service/, + ); + }); + + it(`${script} hands the remaining steps to 'ade setup'`, () => { + const source = fs.readFileSync(path.join(scriptsDir, script), "utf8"); + expect(source).toMatch(/"setup"|setup --continue/); + }); + } +}); diff --git a/apps/ade-cli/src/commands/setup.ts b/apps/ade-cli/src/commands/setup.ts new file mode 100644 index 000000000..25bb2340a --- /dev/null +++ b/apps/ade-cli/src/commands/setup.ts @@ -0,0 +1,551 @@ +/** + * `ade setup` -- the interactive half of installation. + * + * The shell installers can only own what happens before this binary exists: + * download the runtime, verify it, register the brain. Everything after that + * (agent CLIs, account, desktop app, verification, the closing summary) lives + * here, once, in a form that can be unit-tested -- instead of twice, in + * PowerShell and POSIX sh, where the two copies had already drifted. + * + * The installer invokes it as: + * ade setup --continue --runtime-version --runtime-path

\ + * --elapsed-ms --downloaded-bytes + * so the summary can report all five steps even though the first two happened + * before this process started. + * + * Every step is independently idempotent and non-fatal: a failure marks its own + * step and the run continues, because a failed sign-in must not cost the user + * the desktop app. Nothing here reports success it did not achieve. + */ +import { + SetupReporter, + detectTerminalCapabilities, + type SetupProgress, + type SetupStep, + type SetupTotals, +} from "./setupRender"; +import { + assetUrl, + defaultDesktopAppPath, + downloadWithResume, + installDesktopArtifact, + launchDesktopApp, + manifestNameForPlatform, + parseDesktopManifest, + sha512Base64, + DEFAULT_ADE_RELEASE_REPO, + type DesktopManifestEntry, +} from "./setupDesktop"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export class SetupUsageError extends Error {} + +export type SetupOptions = { + /** Installer already printed the banner and steps 1-2. */ + continueFromInstaller: boolean; + desktop: boolean; + prompt: boolean; + runtimeVersion: string | null; + runtimePath: string | null; + nativePath: string | null; + elapsedMs: number; + downloadedBytes: number; + repo: string; + releaseVersion: string; +}; + +export type SetupAccountStatus = { + signedIn: boolean; + identity: string | null; +}; + +export type SetupStepResult = { + ok: boolean; + detail: string; + nextAction?: string; +}; + +export type SetupDeps = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + now?: () => number; + reporter?: SetupReporter; + /** Numbered/boolean prompt. Returns the chosen index, or null when unusable. */ + ask?: (question: string, choices: readonly string[]) => Promise; + + ensureAgentTools: ( + onProgress: (progress: SetupProgress) => void, + ) => Promise; + getAccountStatus: () => Promise; + runConnect: () => Promise; + readInstalledDesktop: () => { version: string | null; path: string | null }; + fetchImpl?: typeof fetch; + installDesktop?: typeof installDesktopArtifact; + launchDesktop?: typeof launchDesktopApp; + /** Final end-to-end check; its failure downgrades the summary heading. */ + verify: () => Promise; +}; + +export type SetupResult = { + ok: boolean; + action: "setup"; + steps: SetupStep[]; + verified: boolean; + totals: SetupTotals; +}; + +function readValue(args: string[], flag: string): string | null { + const index = args.indexOf(flag); + if (index === -1) return null; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new SetupUsageError(`${flag} requires a value`); + } + args.splice(index, 2); + return value; +} + +function readSwitch(args: string[], flag: string): boolean { + const index = args.indexOf(flag); + if (index === -1) return false; + args.splice(index, 1); + return true; +} + +function readNumber(args: string[], flag: string): number { + const raw = readValue(args, flag); + if (raw === null) return 0; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) { + throw new SetupUsageError(`${flag} must be a non-negative number`); + } + return value; +} + +export function parseSetupArgs( + rest: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): SetupOptions { + const args = [...rest]; + const continueFromInstaller = readSwitch(args, "--continue"); + const noDesktop = readSwitch(args, "--no-desktop"); + const noPrompt = readSwitch(args, "--no-prompt"); + const runtimeVersion = readValue(args, "--runtime-version"); + const runtimePath = readValue(args, "--runtime-path"); + const nativePath = readValue(args, "--native-path"); + const elapsedMs = readNumber(args, "--elapsed-ms"); + const downloadedBytes = readNumber(args, "--downloaded-bytes"); + const unknown = args.find((arg) => arg.startsWith("-")); + if (unknown) throw new SetupUsageError(`Unknown option ${unknown}`); + return { + continueFromInstaller, + desktop: !noDesktop, + prompt: !noPrompt && env.ADE_INSTALL_NO_PROMPT !== "1", + runtimeVersion, + runtimePath, + nativePath, + elapsedMs, + downloadedBytes, + repo: env.ADE_RELEASE_REPO?.trim() || DEFAULT_ADE_RELEASE_REPO, + releaseVersion: env.ADE_VERSION?.trim() || "latest", + }; +} + +/** Reads one line from the terminal. Null when there is no usable terminal. */ +export async function askOnStdin( + question: string, + choices: readonly string[], + write: (text: string) => void, +): Promise { + if (!process.stdin.isTTY) return null; + const rendered = choices.length > 2 + ? `${question}\n${choices + .map((choice, index) => ` ${index + 1}) ${choice}${index === 0 ? " (default)" : ""}`) + .join("\n")}\n Choose [1]: ` + : `${question} [Y/n]: `; + write(rendered); + + const answer = await new Promise((resolve) => { + const finish = (value: string) => { + process.stdin.off("data", onData); + process.stdin.off("end", onEnd); + process.stdin.off("close", onEnd); + process.stdin.pause(); + resolve(value); + }; + const onData = (chunk: Buffer) => finish(chunk.toString("utf8").trim()); + // Without these the installer hangs forever on a closed stdin instead of + // falling through to the default -- a hang is a far worse failure than a + // wrong default, and it strands the user mid-install with no prompt back. + const onEnd = () => finish(""); + process.stdin.resume(); + process.stdin.on("data", onData); + process.stdin.once("end", onEnd); + process.stdin.once("close", onEnd); + }); + + if (choices.length > 2) { + if (answer === "") return 0; + const index = Number(answer) - 1; + return Number.isInteger(index) && index >= 0 && index < choices.length + ? index + : 0; + } + if (answer === "") return 0; + return /^(n|no)$/i.test(answer) ? 1 : 0; +} + +const STEP_LABELS: Record = { + runtime: "ADE runtime", + native: "Native dependencies", + tools: "Agent CLIs", + account: "Account", + desktop: "Desktop app", +}; + +function makeStep(id: SetupStep["id"]): SetupStep { + return { id, label: STEP_LABELS[id] ?? id, state: "pending", detail: "" }; +} + +export async function runSetupCommand( + rest: readonly string[], + deps: SetupDeps, +): Promise { + const env = deps.env ?? process.env; + const platform = deps.platform ?? process.platform; + const now = deps.now ?? Date.now; + const options = parseSetupArgs(rest, env); + const startedAt = now(); + + const reporter = deps.reporter + ?? new SetupReporter( + (text) => process.stderr.write(text), + detectTerminalCapabilities(process.stderr, env, platform), + ); + const ask = deps.ask + ?? ((question, choices) => + askOnStdin(question, choices, (text) => reporter.prompt(text))); + const interactive = options.prompt && reporter.caps.interactive; + + if (!options.continueFromInstaller) reporter.banner(); + + // Steps 1-2 already happened in the shell; carry them so the summary is whole. + const runtimeStep = makeStep("runtime"); + runtimeStep.state = options.runtimeVersion ? "ok" : "skipped"; + runtimeStep.detail = options.runtimeVersion ?? "already installed"; + runtimeStep.location = options.runtimePath ?? undefined; + + // The shell prints this one live while it extracts the archive; carrying it + // here keeps the summary a complete recap rather than one that quietly drops + // a step the user just watched succeed. + const nativeStep = makeStep("native"); + nativeStep.state = options.nativePath ? "ok" : "skipped"; + nativeStep.detail = options.nativePath ? "installed" : "already installed"; + nativeStep.location = options.nativePath ?? undefined; + + const toolsStep = makeStep("tools"); + const accountStep = makeStep("account"); + const desktopStep = makeStep("desktop"); + const steps = [runtimeStep, nativeStep, toolsStep, accountStep, desktopStep]; + let downloadedBytes = options.downloadedBytes; + + // --- step: agent CLIs ------------------------------------------------------ + // Non-fatal by design: the brain retries this in the background on every + // `ade serve`, so a flaky network here costs a wait, not an install. + toolsStep.state = "active"; + reporter.beginStep(toolsStep); + try { + const result = await deps.ensureAgentTools((progress) => { + reporter.updateStep(toolsStep, progress); + }); + toolsStep.state = result.ok ? "ok" : "failed"; + toolsStep.detail = result.detail; + toolsStep.nextAction = result.nextAction; + } catch (error) { + toolsStep.state = "failed"; + toolsStep.detail = describeError(error); + toolsStep.nextAction = "ade tools ensure"; + } + reporter.completeStep(toolsStep); + + // --- step: account --------------------------------------------------------- + try { + await runAccountStep({ step: accountStep, ask, interactive, deps }); + } catch (error) { + accountStep.state = "failed"; + accountStep.detail = describeError(error); + accountStep.nextAction = "ade connect"; + } + reporter.completeStep(accountStep); + + // --- step: desktop app ----------------------------------------------------- + if (!options.desktop) { + desktopStep.state = "skipped"; + desktopStep.detail = "skipped by --no-desktop"; + } else { + try { + downloadedBytes += await runDesktopStep({ + step: desktopStep, + options, + platform, + env, + reporter, + ask, + interactive, + deps, + }); + } catch (error) { + desktopStep.state = "failed"; + desktopStep.detail = describeError(error); + desktopStep.nextAction = "ade setup"; + } + } + reporter.completeStep(desktopStep); + + // --- verification ---------------------------------------------------------- + // "The files copied" is not the same claim as "it works". This is the check + // that would have caught the install reporting success after sign-in failed. + let verified = false; + try { + const result = await deps.verify(); + verified = result.ok; + if (!result.ok && accountStep.state === "ok") { + accountStep.state = "failed"; + accountStep.detail = result.detail; + accountStep.nextAction = result.nextAction ?? "ade connect"; + } + } catch { + verified = false; + } + + const totals: SetupTotals = { + elapsedMs: options.elapsedMs + (now() - startedAt), + downloadedBytes, + }; + reporter.summary(steps, totals); + + return { + ok: steps.every((step) => step.state !== "failed"), + action: "setup", + steps, + verified, + totals, + }; +} + +function applyStepResult( + step: SetupStep, + result: SetupStepResult, + fallbackAction: string, +): void { + step.state = result.ok ? "ok" : "failed"; + step.detail = result.detail; + step.nextAction = result.ok ? undefined : (result.nextAction ?? fallbackAction); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Account step: confirm an existing link rather than re-prompting blind. + * + * The installer used to ask "Sign in or create your ADE account?" on every run, + * including for a machine that was already linked, because it never looked. + */ +async function runAccountStep(args: { + step: SetupStep; + ask: (question: string, choices: readonly string[]) => Promise; + interactive: boolean; + deps: SetupDeps; +}): Promise { + const { step, ask, interactive, deps } = args; + step.state = "active"; + const status = await deps.getAccountStatus(); + const linkedDetail = (identity: string | null) => + `already linked${identity ? ` - ${identity}` : ""}`; + + if (status.signedIn) { + // A null answer means no usable terminal, which reads as "keep what works". + const choice = interactive + ? await ask(` Already linked to ${status.identity ?? "an ADE account"}`, [ + "Keep this account", + "Sign in as someone else", + "Skip for now", + ]) + : 0; + if (choice === null || choice === 0) { + step.state = "ok"; + step.detail = linkedDetail(status.identity); + } else if (choice === 2) { + step.state = "skipped"; + step.detail = "left as-is"; + } else { + applyStepResult(step, await deps.runConnect(), "ade connect"); + } + return; + } + + const choice = interactive + ? await ask(" Sign in or create your ADE account to link this machine?", [ + "Yes", + "No", + ]) + : 1; + if (choice === 0) { + // No live line here: `ade connect` prints its own step checklist, and an + // animated line underneath it would be drawn over and orphaned. + applyStepResult(step, await deps.runConnect(), "ade connect"); + return; + } + step.state = "skipped"; + step.detail = "not linked"; + step.nextAction = "ade connect"; +} + +/** Returns the bytes downloaded, so the summary total stays honest. */ +async function runDesktopStep(args: { + step: SetupStep; + options: SetupOptions; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + reporter: SetupReporter; + ask: (question: string, choices: readonly string[]) => Promise; + interactive: boolean; + deps: SetupDeps; +}): Promise { + const { step, options, platform, env, reporter, ask, interactive, deps } = args; + const fetchImpl = deps.fetchImpl ?? fetch; + + if (platform !== "win32" && platform !== "darwin") { + step.state = "skipped"; + step.detail = "no desktop build for this platform"; + return 0; + } + + const manifestName = manifestNameForPlatform(platform); + const manifestResponse = await fetchImpl( + assetUrl(manifestName, options.repo, options.releaseVersion), + ); + if (!manifestResponse.ok) { + throw new Error(`could not read ${manifestName}`); + } + const manifestText = await manifestResponse.text(); + const entry = parseDesktopManifest(manifestText, platform); + if (!entry) throw new Error(`${manifestName} did not name an installer`); + const releaseVersion = /^\s*version:\s*(\S+)\s*$/m.exec(manifestText)?.[1] ?? null; + + // Skip the gigabyte when this exact version is already on disk. + const installed = deps.readInstalledDesktop(); + if ( + releaseVersion && + installed.version === releaseVersion && + installed.path && + fs.existsSync(installed.path) + ) { + step.state = "ok"; + step.detail = `${releaseVersion} already installed, opening`; + step.location = installed.path; + (deps.launchDesktop ?? launchDesktopApp)(installed.path, platform); + return 0; + } + + if (!interactive) { + step.state = "skipped"; + step.detail = "not installed"; + step.nextAction = "ade setup"; + return 0; + } + + const question = installed.version + ? ` Update the ADE desktop app to ${releaseVersion ?? "the latest build"}? (about 1 GB download)` + : " Install the ADE desktop app? (about 1 GB download)"; + const choice = await ask(question, ["Yes", "No"]); + if (choice !== 0) { + step.state = "skipped"; + step.detail = "not installed"; + step.nextAction = "ade setup"; + return 0; + } + + return await downloadAndInstallDesktop({ + step, + entry, + options, + platform, + env, + reporter, + deps, + }); +} + +async function downloadAndInstallDesktop(args: { + step: SetupStep; + entry: DesktopManifestEntry; + options: SetupOptions; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + reporter: SetupReporter; + deps: SetupDeps; +}): Promise { + const { step, entry, options, platform, env, reporter, deps } = args; + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-dl-")); + const artifact = path.join(stageDir, path.basename(entry.name)); + + step.state = "active"; + reporter.beginStep(step); + try { + const bytes = await downloadWithResume( + assetUrl(entry.name, options.repo, options.releaseVersion), + artifact, + (progress) => { + reporter.updateStep(step, { + fraction: progress.totalBytes + ? progress.receivedBytes / progress.totalBytes + : null, + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + bytesPerSecond: progress.bytesPerSecond, + }); + }, + { fetchImpl: deps.fetchImpl }, + ); + + if (sha512Base64(artifact) !== entry.sha512) { + throw new Error(`checksum mismatch for ${path.basename(entry.name)}`); + } + + const installed = await (deps.installDesktop ?? installDesktopArtifact)( + artifact, + platform, + env, + ); + const appPath = installed.appPath ?? defaultDesktopAppPath(platform, env); + step.state = "ok"; + step.location = appPath ?? undefined; + if (appPath && fs.existsSync(appPath)) { + // Detached: a GUI child would otherwise inherit this console on Windows + // and spray Electron logs over the user's prompt. + (deps.launchDesktop ?? launchDesktopApp)(appPath, platform); + step.detail = "installed, opening now"; + } else { + step.detail = "installed"; + } + return bytes; + } finally { + // Windows only unlinks a name once every handle closes, and the NSIS + // installer can still hold the .exe briefly after spawnSync returns -- + // `force` swallows ENOENT but not EBUSY/EPERM. Retry, then give up quietly: + // a leftover temp file must never turn a successful install into a failure. + try { + fs.rmSync(stageDir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + } catch {} + } +} diff --git a/apps/ade-cli/src/commands/setupDesktop.ts b/apps/ade-cli/src/commands/setupDesktop.ts new file mode 100644 index 000000000..80f38306e --- /dev/null +++ b/apps/ade-cli/src/commands/setupDesktop.ts @@ -0,0 +1,304 @@ +/** + * Desktop-app acquisition for `ade setup`. + * + * This used to live twice -- once in PowerShell, once in POSIX sh -- which is + * how the Windows path lost the progress bar the macOS path had, and how the + * Windows path ended up launching Electron with an inherited console. One + * implementation, two small platform branches at the edges. + * + * The published SHA256SUMS covers only the standalone runtime assets, so the + * desktop artifact is verified against the base64 SHA-512 in the + * electron-updater manifest (`latest.yml` / `latest-mac.yml`) -- the same digest + * the auto-updater checks. + */ +import { createHash } from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + DEFAULT_ADE_RELEASE_REPO, + releaseAssetUrl, +} from "../lib/releaseAssets"; + +export { DEFAULT_ADE_RELEASE_REPO }; + +export type DesktopManifestEntry = { + /** Asset file name as named by the manifest. */ + name: string; + /** Base64 SHA-512, as electron-updater writes it. */ + sha512: string; +}; + +export type DownloadProgress = { + receivedBytes: number; + totalBytes: number | null; + bytesPerSecond: number | null; +}; + +export function manifestNameForPlatform(platform: NodeJS.Platform): string { + return platform === "darwin" ? "latest-mac.yml" : "latest.yml"; +} + +/** + * Thin argument-order adapter over the canonical `releaseAssetUrl`. The release + * feed's latest-vs-tag rule lives in `lib/releaseAssets.ts` and is shared with + * `ade brain update` and the remote-runtime bootstrap; this keeps setup on that + * one implementation instead of a fourth copy of the same URL shape. + * + * Note the downloader below is deliberately *not* `downloadReleaseAsset` from + * that module: this path needs byte progress and Range-resume for a 1 GB + * artifact, which that helper does not provide. + */ +export function assetUrl( + name: string, + repo = DEFAULT_ADE_RELEASE_REPO, + version = "latest", +): string { + return releaseAssetUrl(repo, version, name); +} + +/** + * electron-updater manifests are a `files:` list of `- url:` / `sha512:` pairs + * followed by a top-level `path:`/`sha512:` repeat. We take the first entry + * whose url matches the artifact this platform installs, which is the same one + * the shell implementations picked. + */ +export function parseDesktopManifest( + yaml: string, + platform: NodeJS.Platform, + arch: string = process.arch, +): DesktopManifestEntry | null { + const matches = platform === "darwin" + ? (url: string) => url.endsWith(`-${arch === "arm64" ? "arm64" : "x64"}.zip`) + : (url: string) => url.toLowerCase().endsWith(".exe"); + + let pendingUrl: string | null = null; + for (const rawLine of yaml.split(/\r?\n/)) { + const urlMatch = /^\s*-\s+url:\s*(\S+)\s*$/.exec(rawLine); + if (urlMatch?.[1]) { + pendingUrl = urlMatch[1]; + continue; + } + const shaMatch = /^\s*sha512:\s*(\S+)\s*$/.exec(rawLine); + if (shaMatch?.[1]) { + if (pendingUrl && matches(pendingUrl)) { + return { name: pendingUrl, sha512: shaMatch[1] }; + } + pendingUrl = null; + } + } + return null; +} + +export function sha512Base64(filePath: string): string { + const hash = createHash("sha512"); + hash.update(fs.readFileSync(filePath)); + return hash.digest("base64"); +} + +const RETRYABLE_ATTEMPTS = 3; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Resumable, retrying download. + * + * A dropped connection 800 MB into a 1 GB file re-requests only the remainder + * via a Range header rather than starting over. A server that ignores the range + * (answers 200 instead of 206) is handled by truncating and restarting, because + * appending a full body onto a partial file would silently corrupt it. + */ +export async function downloadWithResume( + url: string, + destination: string, + onProgress: (progress: DownloadProgress) => void, + deps: { + fetchImpl?: typeof fetch; + signal?: AbortSignal; + } = {}, +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + let lastError: unknown = null; + + for (let attempt = 0; attempt < RETRYABLE_ATTEMPTS; attempt += 1) { + const resumeFrom = fs.existsSync(destination) + ? fs.statSync(destination).size + : 0; + const headers: Record = {}; + if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`; + + try { + const response = await fetchImpl(url, { headers, signal: deps.signal }); + if (!response.ok) { + throw new Error(`download failed with HTTP ${response.status}`); + } + // 206 continues the file; 200 means the server sent the whole thing again. + const appending = resumeFrom > 0 && response.status === 206; + if (resumeFrom > 0 && !appending) fs.rmSync(destination, { force: true }); + + const contentLength = Number(response.headers.get("content-length")); + const alreadyHave = appending ? resumeFrom : 0; + const totalBytes = Number.isFinite(contentLength) && contentLength > 0 + ? contentLength + alreadyHave + : null; + + if (!response.body) throw new Error("download response had no body"); + + const handle = fs.openSync(destination, appending ? "a" : "w"); + let received = alreadyHave; + const startedAt = Date.now(); + let lastReport = 0; + try { + for await (const chunk of response.body as unknown as AsyncIterable) { + fs.writeSync(handle, chunk); + received += chunk.byteLength; + // Throttle: a 1 GB download emits tens of thousands of chunks and the + // renderer would spend more time drawing than the socket does reading. + const now = Date.now(); + if (now - lastReport < 100) continue; + lastReport = now; + const elapsedSeconds = (now - startedAt) / 1000; + onProgress({ + receivedBytes: received, + totalBytes, + bytesPerSecond: elapsedSeconds > 0 + ? (received - alreadyHave) / elapsedSeconds + : null, + }); + } + } finally { + fs.closeSync(handle); + } + onProgress({ receivedBytes: received, totalBytes, bytesPerSecond: null }); + return received; + } catch (error) { + lastError = error; + if ((error as { name?: string })?.name === "AbortError") throw error; + if (attempt < RETRYABLE_ATTEMPTS - 1) { + await sleep(500 * 2 ** attempt); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +export type DesktopInstallResult = { + appPath: string | null; + /** Human detail for the step line. */ + detail: string; +}; + +/** Where the installed app lives, for the "already installed" short-circuit. */ +export function defaultDesktopAppPath( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, +): string | null { + if (platform === "win32") { + const local = env.LOCALAPPDATA; + return local ? path.join(local, "Programs", "ADE", "ADE.exe") : null; + } + if (platform === "darwin") return "/Applications/ADE.app"; + return null; +} + +/** + * Windows: the NSIS installer is built oneClick:false + perMachine:false + + * allowElevation:false, so `/S` is a silent per-user install with no UAC prompt. + * macOS: expand the zip and promote ADE.app, moving any existing copy aside + * first so a failed promotion leaves the user's working app in place. + */ +export async function installDesktopArtifact( + artifactPath: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (platform === "win32") { + // windowsHide: omitting it is the class behind the host-loss incident in + // WINDOWS_PORT.md -- console windows piling up and outliving their parent. + const result = spawnSync(artifactPath, ["/S"], { + stdio: "ignore", + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`the desktop installer exited with code ${result.status}`); + } + const appPath = defaultDesktopAppPath("win32", env); + return { + appPath: appPath && fs.existsSync(appPath) ? appPath : null, + detail: "installed", + }; + } + + if (platform === "darwin") { + const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-")); + const extract = spawnSync("ditto", ["-x", "-k", artifactPath, stage], { + stdio: "ignore", + windowsHide: true, + }); + if (extract.status !== 0) { + throw new Error("could not expand the desktop app archive"); + } + const staged = path.join(stage, "ADE.app"); + if (!fs.existsSync(staged)) { + throw new Error("desktop app archive did not contain ADE.app"); + } + const appsDir = canWrite("/Applications") + ? "/Applications" + : path.join(os.homedir(), "Applications"); + fs.mkdirSync(appsDir, { recursive: true }); + const target = path.join(appsDir, "ADE.app"); + const backup = `${target}.previous`; + fs.rmSync(backup, { recursive: true, force: true }); + if (fs.existsSync(target)) fs.renameSync(target, backup); + try { + fs.renameSync(staged, target); + } catch (error) { + if (fs.existsSync(backup)) fs.renameSync(backup, target); + throw error; + } + fs.rmSync(backup, { recursive: true, force: true }); + fs.rmSync(stage, { recursive: true, force: true }); + return { appPath: target, detail: "installed" }; + } + + return { appPath: null, detail: "not available on this platform" }; +} + +function canWrite(target: string): boolean { + try { + fs.accessSync(target, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +/** + * Launch the app without handing it our console. + * + * On Windows a GUI child inherits the parent's console handles, so Electron's + * main-process logging lands in the user's shell and the prompt never comes + * back clean. `detached` + `stdio: "ignore"` + `unref()` is what makes it behave + * like a double-click. macOS delegates to LaunchServices, which already detaches. + */ +export function launchDesktopApp( + appPath: string, + platform: NodeJS.Platform = process.platform, +): void { + const child = platform === "darwin" + ? spawn("open", [appPath], { detached: true, stdio: "ignore" }) + : spawn(appPath, [], { + detached: true, + stdio: "ignore", + // Hides a stray *console* window, not the app's own GUI window -- an + // Electron app never creates the former, so this is free insurance and + // keeps every spawn in the repo consistent with the windowsHide rule. + windowsHide: true, + }); + child.once("error", () => {}); + child.unref(); +} diff --git a/apps/ade-cli/src/commands/setupRender.ts b/apps/ade-cli/src/commands/setupRender.ts new file mode 100644 index 000000000..ebf018bd8 --- /dev/null +++ b/apps/ade-cli/src/commands/setupRender.ts @@ -0,0 +1,356 @@ +/** + * Terminal rendering for `ade setup`. + * + * Split out from the orchestration in ./setup so the layout is unit-testable + * without running an install: every function here is pure except the reporter, + * which owns the only stream writes. + * + * Layout contract (chosen with the user, do not drift): + * - finished steps scroll away as static lines; only the active step animates + * - a failure states the reason in plain language AND the fix, inline + * - the closing summary recaps every step; the "What's left" block appears + * only when something actually needs the user + */ + +export type SetupStepId = + | "runtime" + | "native" + | "tools" + | "account" + | "desktop"; + +/** `ok`/`skipped`/`failed` deliberately mirror ConnectStepState in ./connect. */ +export type SetupStepState = + | "pending" + | "active" + | "ok" + | "skipped" + | "failed"; + +export type SetupStep = { + id: SetupStepId; + label: string; + state: SetupStepState; + /** Short human detail: a version, a duration, or why it failed. */ + detail: string; + /** Command that resolves a failure. Printed inline and in the summary. */ + nextAction?: string; + /** Where the thing landed, shown in the summary when we know it. */ + location?: string; +}; + +export type SetupProgress = { + /** 0..1 when known; null renders an indeterminate marker instead of a bar. */ + fraction: number | null; + receivedBytes?: number | null; + totalBytes?: number | null; + bytesPerSecond?: number | null; + /** Sub-item currently being worked on, e.g. `claude-code`. */ + item?: string | null; +}; + +export type TerminalCapabilities = { + /** A real terminal we may prompt on and redraw in. */ + interactive: boolean; + /** Cursor movement and in-place redraw are safe. */ + ansi: boolean; + color: boolean; + unicode: boolean; + columns: number; +}; + +// Built from a char code so no raw 0x1B byte ever lands in this source file -- +// a literal escape survives compilation fine but corrupts in diffs, editors, +// and anything that normalizes control characters. +const CSI = `${String.fromCharCode(27)}[`; +/** Carriage return + erase-whole-line: what rewrites the animated line in place. */ +const CLEAR_LINE = `\r${CSI}2K`; + +const BANNER = String.raw` + _ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| +`; + +export function detectTerminalCapabilities( + stream: { isTTY?: boolean; columns?: number }, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): TerminalCapabilities { + const isTty = stream.isTTY === true; + // CI passes isTTY often enough, but redraw there produces unreadable logs. + // Treat it as a pipe. + const isCi = Boolean(env.CI && env.CI !== "0" && env.CI !== "false"); + const dumb = env.TERM === "dumb"; + const ansi = isTty && !isCi && !dumb; + const color = ansi && env.NO_COLOR === undefined && env.ADE_NO_COLOR !== "1"; + // Legacy conhost renders box-drawing and check marks as mojibake. Modern + // hosts advertise themselves; assume ASCII on Windows when none of them do. + const modernWindowsHost = Boolean( + env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuANSI || env.TERM, + ); + const unicode = ansi && (platform !== "win32" || modernWindowsHost); + return { + interactive: isTty && !isCi, + ansi, + color, + unicode, + columns: Math.max(40, Math.min(stream.columns ?? 80, 120)), + }; +} + +function paint(text: string, code: string, caps: TerminalCapabilities): string { + return caps.color ? `${CSI}${code}m${text}${CSI}0m` : text; +} + +export function stateSymbol( + state: SetupStepState, + caps: TerminalCapabilities, +): string { + const unicodeSymbols: Record = { + pending: " ", + active: "·", + ok: "✓", + skipped: "-", + failed: "✗", + }; + const asciiSymbols: Record = { + pending: " ", + active: ">", + ok: "+", + skipped: "-", + failed: "x", + }; + const symbol = caps.unicode ? unicodeSymbols[state] : asciiSymbols[state]; + if (state === "ok") return paint(symbol, "32", caps); + if (state === "failed") return paint(symbol, "31", caps); + return symbol; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "0 MB"; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + const mb = bytes / (1024 * 1024); + if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`; + return `${(mb / 1024).toFixed(1)} GB`; +} + +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "0s"; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${String(seconds).padStart(2, "0")}s`; +} + +export function renderProgressBar( + fraction: number, + width: number, + caps: TerminalCapabilities, +): string { + const clamped = Math.max(0, Math.min(1, fraction)); + const filledCount = Math.round(clamped * width); + const [filledChar, emptyChar] = caps.unicode + ? ["█", "░"] + : ["#", "."]; + return filledChar.repeat(filledCount) + emptyChar.repeat(width - filledCount); +} + +/** The one animated line: `###... 38% Desktop app - 412/1084 MB - 9.4 MB/s`. */ +export function formatActiveLine( + step: SetupStep, + progress: SetupProgress | null, + caps: TerminalCapabilities, +): string { + const parts: string[] = []; + if (progress?.fraction != null) { + const percent = Math.round(Math.max(0, Math.min(1, progress.fraction)) * 100); + parts.push(renderProgressBar(progress.fraction, 12, caps)); + parts.push(`${String(percent).padStart(3, " ")}%`); + } else { + parts.push(stateSymbol("active", caps)); + } + parts.push(step.label); + + const suffix: string[] = []; + if (progress?.item) suffix.push(progress.item); + if ( + typeof progress?.receivedBytes === "number" && + typeof progress?.totalBytes === "number" && + progress.totalBytes > 0 + ) { + suffix.push( + `${formatBytes(progress.receivedBytes)}/${formatBytes(progress.totalBytes)}`, + ); + } else if (typeof progress?.receivedBytes === "number") { + suffix.push(formatBytes(progress.receivedBytes)); + } + if ( + typeof progress?.bytesPerSecond === "number" && + progress.bytesPerSecond > 0 + ) { + suffix.push(`${formatBytes(progress.bytesPerSecond)}/s`); + } + const separator = caps.unicode ? " · " : " - "; + const line = suffix.length > 0 + ? ` ${parts.join(" ")}${separator}${suffix.join(separator)}` + : ` ${parts.join(" ")}`; + // Never wrap: a wrapped line cannot be erased by a single clear-line, which + // would leave orphaned progress bars scrolling up the screen. + return line.length > caps.columns ? line.slice(0, caps.columns - 1) : line; +} + +/** A finished step, as it scrolls away: `+ Agent CLIs codex, claude-code`. */ +export function formatCompletedLine( + step: SetupStep, + caps: TerminalCapabilities, +): string { + const label = step.label.padEnd(LABEL_WIDTH, " "); + const head = ` ${stateSymbol(step.state, caps)} ${label} ${step.detail}`.trimEnd(); + if (step.state !== "failed" || !step.nextAction) return head; + // The fix goes inline, under the failure, aligned past the symbol column. + return `${head}\n fix later with: ${step.nextAction}`; +} + +export type SetupTotals = { + elapsedMs: number; + downloadedBytes: number; +}; + +/** + * What the recovery command gets the user, for the "What's left" block. + * + * The failure reason is already on the step's own line two rows above, so + * repeating it there would read as "ade connect -- sign-in didn't finish". + * What a stuck user needs is what the command is *for*. + */ +const NEXT_ACTION_PURPOSE: Record = { + runtime: "reinstall the ADE runtime", + native: "reinstall the ADE runtime", + tools: "fetch the agent CLIs", + account: "link this machine to your account", + desktop: "install the desktop app", +}; + +/** Widest label ("Native dependencies") plus a space, so columns line up. */ +const LABEL_WIDTH = 20; + +export function renderBanner(): string { + // Pure ASCII art already -- nothing to downgrade for legacy terminals. + return BANNER; +} + +/** + * The closing block. `What's left` is emitted only when a step actually failed, + * so a clean install never shows an empty to-do list. + */ +export function renderSummary( + steps: readonly SetupStep[], + totals: SetupTotals, + caps: TerminalCapabilities, +): string { + const failed = steps.filter((step) => step.state === "failed"); + const heading = failed.length === 0 + ? "ADE is ready" + : `ADE installed — ${failed.length} step${failed.length === 1 ? "" : "s"} need${failed.length === 1 ? "s" : ""} you`; + + const lines: string[] = ["", ` ${heading}`, ""]; + for (const step of steps) { + if (step.state === "pending") continue; + const label = step.label.padEnd(LABEL_WIDTH, " "); + // Where it landed is more useful than "3.1s" once the install is over. + const detail = step.location ?? step.detail; + lines.push(` ${stateSymbol(step.state, caps)} ${label} ${detail}`.trimEnd()); + } + + if (failed.length > 0) { + lines.push("", " What's left"); + for (const step of failed) { + if (!step.nextAction) continue; + const purpose = NEXT_ACTION_PURPOSE[step.id] ?? step.detail; + lines.push(` ${step.nextAction.padEnd(LABEL_WIDTH, " ")} ${purpose}`.trimEnd()); + } + } + + lines.push(""); + lines.push( + totals.downloadedBytes > 0 + ? ` Installed in ${formatDuration(totals.elapsedMs)}, ${formatBytes(totals.downloadedBytes)} downloaded` + : ` Installed in ${formatDuration(totals.elapsedMs)}`, + ); + return `${lines.join("\n")}\n`; +} + +/** + * Owns every write to the output stream. + * + * ANSI mode keeps exactly one animated line alive at the bottom and erases it + * before printing anything static, so completed steps and prompts never collide + * with a half-drawn progress bar. Non-ANSI mode degrades to one appended line + * per step -- no cursor movement, nothing to corrupt a log file. + */ +export class SetupReporter { + private activeLineDrawn = false; + + constructor( + private readonly write: (text: string) => void, + readonly caps: TerminalCapabilities, + ) {} + + banner(): void { + this.write(`${renderBanner()}\n`); + } + + /** Static output. Clears any live line first so nothing is left half-drawn. */ + line(text: string): void { + this.clearActive(); + this.write(`${text}\n`); + } + + /** + * A prompt, written verbatim with no trailing newline so the caret stays on + * the question's own line and the user types their answer next to it. + */ + prompt(text: string): void { + this.clearActive(); + this.write(text); + } + + beginStep(step: SetupStep): void { + if (this.caps.ansi) { + this.drawActive(step, null); + return; + } + this.write(` ${step.label}...\n`); + } + + updateStep(step: SetupStep, progress: SetupProgress | null): void { + if (!this.caps.ansi) return; // no redraw target; completion line reports it + this.drawActive(step, progress); + } + + completeStep(step: SetupStep): void { + this.clearActive(); + this.write(`${formatCompletedLine(step, this.caps)}\n`); + } + + summary(steps: readonly SetupStep[], totals: SetupTotals): void { + this.clearActive(); + this.write(renderSummary(steps, totals, this.caps)); + } + + /** Called before prompting and on exit, so we never abandon a drawn line. */ + clearActive(): void { + if (!this.activeLineDrawn) return; + this.activeLineDrawn = false; + if (this.caps.ansi) this.write(CLEAR_LINE); + } + + private drawActive(step: SetupStep, progress: SetupProgress | null): void { + this.write(`${CLEAR_LINE}${formatActiveLine(step, progress, this.caps)}`); + this.activeLineDrawn = true; + } +} diff --git a/apps/ade-cli/src/lib/nodeWarnings.test.ts b/apps/ade-cli/src/lib/nodeWarnings.test.ts new file mode 100644 index 000000000..bbabe7258 --- /dev/null +++ b/apps/ade-cli/src/lib/nodeWarnings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { isSuppressedNodeWarning } from "./nodeWarnings"; + +describe("isSuppressedNodeWarning", () => { + it("suppresses the node:sqlite experimental notice users saw on every command", () => { + expect( + isSuppressedNodeWarning( + "SQLite is an experimental feature and might change at any time", + ["ExperimentalWarning"], + ), + ).toBe(true); + }); + + it("reads the type from the options-object call form", () => { + expect( + isSuppressedNodeWarning("SQLite is an experimental feature", [ + { type: "ExperimentalWarning" }, + ]), + ).toBe(true); + }); + + it("reads the type from an Error's name", () => { + const warning = new Error("SQLite is an experimental feature"); + warning.name = "ExperimentalWarning"; + expect(isSuppressedNodeWarning(warning)).toBe(true); + }); + + it("keeps other experimental warnings -- only SQLite is known noise", () => { + expect( + isSuppressedNodeWarning("Fetch API is an experimental feature", [ + "ExperimentalWarning", + ]), + ).toBe(false); + }); + + it("keeps deprecations and every other warning class", () => { + expect( + isSuppressedNodeWarning("SQLite thing is deprecated", [ + "DeprecationWarning", + ]), + ).toBe(false); + expect(isSuppressedNodeWarning("something broke")).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/lib/nodeWarnings.ts b/apps/ade-cli/src/lib/nodeWarnings.ts new file mode 100644 index 000000000..18aeb3f77 --- /dev/null +++ b/apps/ade-cli/src/lib/nodeWarnings.ts @@ -0,0 +1,89 @@ +/** + * Node prints `ExperimentalWarning: SQLite is an experimental feature` on every + * single `ade` invocation, because `node:sqlite` is the primary database engine. + * Users see it three or four times during an install and read it, reasonably, as + * something being broken. + * + * `--no-warnings` is not reachable here: the shipped CLI is a single-executable + * Node build, so there is no shebang or NODE_OPTIONS the user controls. The + * filter has to happen in-process. + * + * We wrap `process.emitWarning` rather than removing the `warning` listeners. + * Removing listeners means re-implementing Node's own output format for every + * warning we *do* want to show; wrapping the emitter leaves that formatting + * untouched and simply drops the one warning class we know is noise. + */ + +/** Escape hatch: set to 1 to see every warning Node emits, unfiltered. */ +const SHOW_WARNINGS_ENV = "ADE_SHOW_NODE_WARNINGS"; + +let installed = false; + +function warningType( + warning: string | Error, + rest: readonly unknown[], +): string | null { + // process.emitWarning(warning, type?, code?, ctor?) + const [second] = rest; + if (typeof second === "string") return second; + // process.emitWarning(warning, { type, code, detail }) + if (second && typeof second === "object" && "type" in second) { + const { type } = second as { type?: unknown }; + if (typeof type === "string") return type; + } + // process.emitWarning(new Error(...)) — the name carries the type. + if (typeof warning !== "string" && typeof warning?.name === "string") { + return warning.name; + } + return null; +} + +function warningMessage(warning: string | Error): string { + return typeof warning === "string" ? warning : (warning?.message ?? ""); +} + +/** + * Only the SQLite experimental notice. Every other warning — deprecations, our + * own `process.emitWarning` calls, unhandled rejection notices — still prints, + * because those are ones a user or a bug report genuinely needs. + */ +export function isSuppressedNodeWarning( + warning: string | Error, + rest: readonly unknown[] = [], +): boolean { + if (warningType(warning, rest) !== "ExperimentalWarning") return false; + return /\bsqlite\b/i.test(warningMessage(warning)); +} + +/** + * Idempotent. Safe to call from any entry point; the CLI calls it first so the + * wrap is in place before `node:sqlite` is loaded anywhere in the process. + */ +export function installNodeWarningFilter( + env: NodeJS.ProcessEnv = process.env, +): void { + if (installed) return; + if (env[SHOW_WARNINGS_ENV] === "1") return; + installed = true; + + const original = process.emitWarning.bind(process); + process.emitWarning = (( + warning: string | Error, + ...rest: unknown[] + ): void => { + if (isSuppressedNodeWarning(warning, rest)) return; + (original as (...args: unknown[]) => void)(warning, ...rest); + }) as typeof process.emitWarning; +} + +/** Test-only: lets a suite install the filter against a fresh module state. */ +export function resetNodeWarningFilterForTests(): void { + installed = false; +} + +// Self-installing on import, deliberately. `node:sqlite` emits its warning the +// moment it is first required, and ES module imports are hoisted above every +// top-level statement -- so a call placed in cli.ts's body would run *after* +// the imports that pull in the database layer, and the warning would already +// have printed. Importing this module first is the only ordering that works. +installNodeWarningFilter(); diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 5c44d9524..b6416cfdd 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -882,12 +882,30 @@ grep-guarded so re-running the installer never duplicates it; fish and unrecognized shells get printed instructions instead, as do non-interactive runs and `ADE_INSTALL_NO_PATH=1`. -On an interactive terminal the script then offers to run `ade connect`, -which signs the machine in, ensures the login service, and publishes the -machine to the account directory so desktop, web, and iOS can reach it. +The shell scripts own only what must happen before the `ade` binary +exists: downloading and verifying the runtime, and registering the brain +service. They register it through `ade brain start`, which pins the +runtime's default role to `cto` — the role `ade connect` and the desktop +app both require. Registering through `serve --install-service` instead +inherits an unset `ADE_DEFAULT_ROLE`, which lands on `agent` and makes +sign-in fail on every clean install. + +Everything after that is `ade setup`, one implementation both platforms +hand off to, so Windows and macOS cannot drift. It runs the remaining +steps — pinned agent CLIs, account, desktop app — then verifies the +install end to end (brain running, machine linked) and prints a summary +recapping all five steps. A step that fails names the command that fixes +it, inline and again under "What's left"; a clean run prints neither. + +The account step checks first: an already-linked machine is offered +keep / switch / skip rather than being asked to sign in blind. The +desktop step skips its ~1 GB download when that exact version is already +installed, resumes an interrupted download via a Range request, and +launches the app detached so a Windows console is never inherited. Use `ade connect --headless` when there is no browser. Non-interactive contexts skip the prompts and print the follow-up commands; -`ADE_INSTALL_NO_PROMPT=1` (or `-NoPrompt`) opts out explicitly. +`ADE_INSTALL_NO_PROMPT=1` (or `-NoPrompt`) opts out explicitly. Running +`ade setup` later re-runs the same flow on its own. See [`apps/ade-cli/README.md`](../../../apps/ade-cli/README.md) for the full flow and environment overrides. From 1f73ddf0ac5c5a0dbcedf6ce550da2a5daaca19b Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Wed, 5 Aug 2026 15:56:21 -0400 Subject: [PATCH 2/4] fix(setup): verify the machine published, not just that it signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification step added in this branch checked "brain running + account signed in" and called that ready. A clean Windows install reproduced exactly that state while never reaching the account directory, so the installer would have printed "ADE is ready" over a machine that is absent from the user's account. Sign-in is not the outcome; publication is. Verification now reads the brain's own account-directory publisher health and fails when the machine is not published. It also translates the publisher's internal state into an action. The publisher's only snapshot source is the active project's sync host (cli.ts:17011-17016), so a machine with no project registered emits `no_active_sync_scope` and publishes nothing. The user-facing copy now says to open a project rather than repeating "No active sync scope is available." — the raw diagnostic the Connections pane surfaces today. Absent health is treated as pass, not fail: the publisher runs on a 30s heartbeat and a fresh install can outrun its first attempt. `describeUnpublishedMachine` lives in commands/setup.ts rather than cli.ts so it is testable without importing the whole dispatcher. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/cli.ts | 69 +++++++++++++++++++++++-- apps/ade-cli/src/commands/setup.test.ts | 42 +++++++++++++++ apps/ade-cli/src/commands/setup.ts | 28 ++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 15d3477a5..f7b6bbf1a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -15622,6 +15622,63 @@ async function readSetupAccountStatus( } } +/** + * Has this machine actually reached the account directory? + * + * Read from the brain's own publisher health rather than re-polling the + * directory: it is the component that would have done the POST, so its state + * is the honest answer, and an unreachable brain degrades to "unknown" instead + * of a false negative. + */ +async function verifyMachinePublished( + options: GlobalOptions, + identity: string | null, +): Promise<{ ok: boolean; detail: string; nextAction?: string }> { + let connection: CliConnection | null = null; + try { + connection = await createConnection( + { ...options, headless: false, role: "cto" }, + { autoRegisterProject: false, machineRuntimeOnly: true }, + ); + const status = unwrapActionEnvelope( + await connection.request("sync.getStatus", { includeTransferReadiness: false }), + ); + const routeHealth = isRecord(status) && isRecord(status.routeHealth) + ? status.routeHealth + : null; + const directory = routeHealth && isRecord(routeHealth.accountDirectory) + ? routeHealth.accountDirectory + : null; + const state = asString(directory?.state); + if (!state) { + // No health yet is not proof of failure -- the publisher runs on a 30s + // heartbeat and a fresh install can outrun its first attempt. + return { + ok: true, + detail: `ade ${VERSION}, brain running, signed in as ${identity ?? "your account"}`, + }; + } + if (state === "published") { + return { + ok: true, + detail: `ade ${VERSION}, brain running, ${identity ?? "your account"} — machine published`, + }; + } + const { describeUnpublishedMachine } = await import("./commands/setup"); + const described = describeUnpublishedMachine(state, asString(directory?.skipReason)); + return { ok: false, ...described }; + } catch { + return { + ok: true, + detail: `ade ${VERSION}, brain running, signed in as ${identity ?? "your account"}`, + }; + } finally { + try { + await connection?.close(); + } catch {} + } +} + /** * `ade setup` — the interactive half of installation. * @@ -15690,10 +15747,14 @@ async function runSetupCli( nextAction: "ade connect", }; } - return { - ok: true, - detail: `ade ${VERSION}, brain running, signed in as ${account.identity ?? "your account"}`, - }; + // Signed in is NOT the outcome the user wants -- published is. A + // machine can be signed in, with a healthy brain, and still be absent + // from the account directory, which is exactly what a clean install + // looks like today: the account-directory publisher's only snapshot + // source is a project-scoped sync host, so a machine with no project + // registered publishes nothing and never appears in the account. + // Verifying `signedIn` alone would report that broken install as ready. + return await verifyMachinePublished(options, account.identity); }, }); return { diff --git a/apps/ade-cli/src/commands/setup.test.ts b/apps/ade-cli/src/commands/setup.test.ts index 1ce0f7f91..e1a549967 100644 --- a/apps/ade-cli/src/commands/setup.test.ts +++ b/apps/ade-cli/src/commands/setup.test.ts @@ -427,6 +427,26 @@ describe("runSetupCommand", () => { expect(chunks.join("")).toContain("What's left"); }); + it("fails verification for a signed-in machine that never reached the account", async () => { + // The exact state a clean install lands in: brain healthy, account signed + // in, machine absent from the account directory because no project is + // registered. Verifying sign-in alone would call this a success. + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + verify: async () => ({ + ok: false, + detail: "signed in, but this machine isn't in your account yet", + nextAction: "open a project in ADE to finish linking this machine", + }), + })); + + expect(result.ok).toBe(false); + expect(result.verified).toBe(false); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("failed"); + expect(account?.nextAction).toBe("open a project in ADE to finish linking this machine"); + }); + it("downgrades a step that reported ok when verification disagrees", async () => { // This is the exact shape of the bug: the sign-in step believed it worked // and the installer printed a next step, while the machine was not linked. @@ -546,3 +566,25 @@ describe("installer brain registration (regression)", () => { }); } }); + +// The publisher's skipReason strings are internal diagnostics. The desktop +// Connections pane concatenates them into user-facing copy verbatim, which is +// how "No active sync scope is available." reached a real user's screen. +describe("describeUnpublishedMachine", () => { + it("translates no_active_sync_scope into the action that clears it", async () => { + const { describeUnpublishedMachine } = await import("./setup"); + const described = describeUnpublishedMachine( + "no_active_sync_scope", + "No active sync scope is available.", + ); + expect(described.detail).not.toContain("sync scope"); + expect(described.nextAction).toBe("open a project in ADE to finish linking this machine"); + }); + + it("keeps the reason visible for states it has no specific advice for", async () => { + const { describeUnpublishedMachine } = await import("./setup"); + expect(describeUnpublishedMachine("http_error", "directory returned 503").detail) + .toContain("directory returned 503"); + expect(describeUnpublishedMachine("http_error", null).detail).toContain("http error"); + }); +}); diff --git a/apps/ade-cli/src/commands/setup.ts b/apps/ade-cli/src/commands/setup.ts index 25bb2340a..e90991fbd 100644 --- a/apps/ade-cli/src/commands/setup.ts +++ b/apps/ade-cli/src/commands/setup.ts @@ -42,6 +42,34 @@ import path from "node:path"; export class SetupUsageError extends Error {} +/** + * Turn an account-directory publisher state into copy a person can act on. + * + * The publisher's `skipReason` strings are internal diagnostics ("No active + * sync scope is available."). Surfacing them verbatim -- which is what the + * desktop Connections pane does today -- tells a user that a fault occurred + * and nothing whatsoever about how to clear it. + */ +export function describeUnpublishedMachine( + state: string, + skipReason: string | null, +): { detail: string; nextAction: string } { + if (state === "no_active_sync_scope") { + return { + detail: "signed in, but this machine isn't in your account yet", + // The publisher's only snapshot source is the active project's sync host, + // so with no project registered there is nothing for it to publish. + nextAction: "open a project in ADE to finish linking this machine", + }; + } + return { + detail: skipReason?.trim() + ? `not published to your account (${skipReason.trim()})` + : `not published to your account (${state.replaceAll("_", " ")})`, + nextAction: "ade connect", + }; +} + export type SetupOptions = { /** Installer already printed the banner and steps 1-2. */ continueFromInstaller: boolean; From 3d8e558b29a360e404c21c84ff43fbcc422eb161 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Wed, 5 Aug 2026 18:34:17 -0400 Subject: [PATCH 3/4] fix(sync): publish a signed-in machine with no project, and dial the relay A signed-in machine with no registered project could never appear in its owner's ADE account. The account-directory publisher's only snapshot source was a project-scoped sync host (cli.ts getSnapshot -> resolveActiveSyncHost), so on a clean install it returned null, the publisher bailed with `no_active_sync_scope` before any network call, and the machine stayed invisible. Reproduced on a clean Windows box; the code path has no platform branches, so macOS and Linux behaved identically. This was never intended. runServe already treats projectless serving as a supported hosting state -- it takes the machine-wide sync-host lease and binds the shared listener -- and its own comment says such a brain would "bind the port, publish itself, and dial the relay". It bound the port and did neither of the other two, because getMachineOnlySyncStatus returned a hardcoded all-down literal that was a lie in exactly that case. - new projectlessSyncSnapshot builds an honest snapshot when the lease is held and the listener is bound: real port, real pairing connect info, host role. The publisher falls back to it only while genuinely hosting, so `no_active_sync_scope` stays an honest diagnosis otherwise. - new machineRelayTunnel dials the relay on that path, reusing the shared tunnel-client cache key so a project scope booting later adopts the client instead of re-registering and evicting itself. - a headless Linux box with an empty projects.json now publishes and is reachable off-LAN, which is the whole point of the one-liner install. Pairing stays unset by design: account membership is the auth path and the pairing code is a nearby-device fallback. The published endpoints still enforce pairing-store auth, DPoP binding, and account attestation. Also folds in the review findings this surfaced: - one shared per-state advice table replaces hand-mirrored copies in the CLI and the Connections pane that had already drifted; the pane no longer renders the publisher's internal skipReason at users - "open a project" is gone from every surface and doc: this change makes that case publish, so the advice could no longer help anyone - syncRouteHealth extracts route-health derivation shared by syncService and the projectless builder, flattening a six-level nested ternary - desktop install: streamed SHA-512 instead of buffering ~1 GB, a lock over the shared download cache, a working retry on Windows cleanup, and a macOS rollback that can no longer destroy a working app - installers no longer fail a whole `curl | sh` because an optional post-install step flaked Co-Authored-By: Claude Opus 5 --- apps/ade-cli/README.md | 1 + apps/ade-cli/scripts/install-runtime.ps1 | 34 +- apps/ade-cli/scripts/install-runtime.sh | 27 +- apps/ade-cli/src/bootstrap.ts | 78 +--- apps/ade-cli/src/cli.ts | 106 +++++- apps/ade-cli/src/commands/setup.test.ts | 339 +++++++++++++++++- apps/ade-cli/src/commands/setup.ts | 171 ++++++--- apps/ade-cli/src/commands/setupDesktop.ts | 125 ++++++- apps/ade-cli/src/commands/setupRender.ts | 3 +- apps/ade-cli/src/lib/nodeWarnings.test.ts | 21 +- apps/ade-cli/src/lib/nodeWarnings.ts | 8 + .../ade-cli/src/multiProjectRpcServer.test.ts | 62 +++- apps/ade-cli/src/multiProjectRpcServer.ts | 121 +------ .../accountMachinePublisherService.test.ts | 66 +++- .../services/sync/deviceRegistryService.ts | 60 ++-- .../src/services/sync/machineRelayTunnel.ts | 101 ++++++ .../services/sync/projectlessSyncSnapshot.ts | 231 ++++++++++++ .../src/services/sync/syncRouteHealth.ts | 171 +++++++++ .../src/services/sync/syncService.test.ts | 288 +++++++++++++++ apps/ade-cli/src/services/sync/syncService.ts | 83 ++--- apps/desktop/build/installer.nsh | 54 ++- apps/desktop/package.json | 6 - .../scripts/windows-release-contract.test.mjs | 20 ++ .../remoteTargets/RemoteTargetList.test.tsx | 17 +- .../remoteTargets/RemoteTargetList.tsx | 16 +- .../settings/SyncDevicesSection.test.tsx | 99 +++-- .../settings/accountDirectorySummary.ts | 29 +- apps/desktop/src/shared/types/sync.ts | 120 +++++++ .../src/components/install/InstallDialog.tsx | 20 ++ apps/web/src/lib/installTargets.ts | 18 + docs/features/remote-runtime/README.md | 13 +- docs/features/sync-and-multi-device/README.md | 146 +++++++- 32 files changed, 2247 insertions(+), 407 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/machineRelayTunnel.ts create mode 100644 apps/ade-cli/src/services/sync/projectlessSyncSnapshot.ts create mode 100644 apps/ade-cli/src/services/sync/syncRouteHealth.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 1234af986..0b43a3146 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -28,6 +28,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab | `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | | `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. | | `~/.ade/runtime//` | Native node modules for that runtime binary. | +| `~/.ade/cache/desktop/` | Partially downloaded desktop installer, kept between `ade setup` runs so an interrupted 1 GB download resumes. Deleted once the install succeeds. | | `~/.ade/runtime/launchd.{out,err}.log` | Runtime stdout/stderr when running as a login service on macOS. | Per-project state stays under `/.ade/` and is governed by `projectConfigService` (see `docs/features/onboarding-and-settings/configuration-schema.md`). Project-scoped ADE secrets live in `/.ade/secrets/project-secrets.v1.enc` and are exposed through `ade secrets` / the `project_secret` action domain. diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index 7978fdc97..ac5d6f2b8 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -118,6 +118,16 @@ function Download-Asset( } Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue + # The Add-Type above is allowed to fail quietly, but the type resolution below + # is not: under $ErrorActionPreference = "Stop" it throws from inside the main + # install try block, so a host without the assembly would take the ROLLBACK + # path instead of just losing its progress bar. Degrade to a plain download. + if (-not ("System.Net.Http.HttpClient" -as [type])) { + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $Destination + $script:DownloadedBytes += (Get-Item -LiteralPath $Destination).Length + return + } + $client = [Net.Http.HttpClient]::new() try { $client.Timeout = [TimeSpan]::FromMinutes(30) @@ -130,13 +140,15 @@ function Download-Asset( $total = if ($response.Content.Headers.ContentLength) { [double]$response.Content.Headers.ContentLength } else { 0 } - $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + # Not $input: that is PowerShell's automatic pipeline enumerator, and + # assigning to it shadows the real one for the rest of the scope. + $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $output = [IO.File]::Create($Destination) try { $buffer = [byte[]]::new(1MB) $received = 0.0 $lastReport = [Environment]::TickCount - while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { + while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $output.Write($buffer, 0, $read) $received += $read # Throttled: redrawing on every 1 MB chunk costs more than the socket. @@ -149,7 +161,7 @@ function Download-Asset( $script:DownloadedBytes += $received } finally { $output.Dispose() - $input.Dispose() + $stream.Dispose() } } finally { $response.Dispose() @@ -490,7 +502,10 @@ if ($installSucceeded) { $runtimeVersion = "" try { - $runtimeVersion = ((& $destinationBinary --version) | Out-String).Trim() + # Collapsed to one line, matching the sh side's `tr -d '\r\n'`: any stray + # warning line ahead of the version would otherwise embed a newline in the + # summary's step detail and break its layout. + $runtimeVersion = (((& $destinationBinary --version) -join " ") -replace '\s+', " ").Trim() } catch { $runtimeVersion = "" } @@ -513,10 +528,8 @@ if ($installSucceeded) { # stdout/stderr are inherited on purpose: the step lines, the prompts and # the summary are meant for this console. & $destinationBinary @setupArgs - $setupExit = $LASTEXITCODE } catch { Write-Warning "Setup did not finish ($($_.Exception.Message)). Run 'ade setup' to try again." - $setupExit = 1 } finally { foreach ($name in $onboardingPreviousEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $onboardingPreviousEnvironment[$name], "Process") @@ -533,5 +546,12 @@ if ($installSucceeded) { Write-Host ' ade is on your PATH in new terminals. This one still needs a restart.' } - if ($setupExit -ne 0) { exit $setupExit } + # `ade setup`'s exit code is deliberately not propagated. Reaching here means + # the runtime is installed and the brain is registered, which is all this + # script promises; the steps `ade setup` owns past that are enhancement, and + # several are documented non-fatal (the brain re-fetches the agent CLIs on + # every `ade serve`). Propagating it failed whole Dockerfiles and CI + # provisioning runs over a flaky fetch. The summary tells the human what is + # left, and `ade setup` still exits non-zero when a person runs it directly. + exit 0 } diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index 9019aa038..8c4a9f0d4 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -58,7 +58,17 @@ download_with_progress() { if command -v curl >/dev/null 2>&1; then curl -fL --progress-bar "$dl_url" -o "$dl_out" elif command -v wget >/dev/null 2>&1; then - wget -q --show-progress "$dl_url" -O "$dl_out" + # `--show-progress` is GNU wget >= 1.16 and busybox wget rejects it outright. + # That flag used to sit on a best-effort, Darwin-only path; it is now on the + # required Linux runtime download, where an Alpine or slim container image + # with no curl would fail the whole install over a progress bar. Plain + # busybox wget already reports progress on stderr, so the fallback loses + # nothing but GNU's verbose header noise. + if wget --help 2>&1 | grep -q -- '--show-progress'; then + wget -q --show-progress "$dl_url" -O "$dl_out" + else + wget "$dl_url" -O "$dl_out" + fi else die "missing curl or wget" fi @@ -522,11 +532,18 @@ set -- "$@" --downloaded-bytes "${downloaded_bytes:-0}" # printing the follow-up commands instead of blocking on an unreadable stdin. [ "$interactive" -eq 1 ] || set -- "$@" --no-prompt -setup_status=0 +# `|| true`, and a flat `exit 0` at the end of the script rather than ade setup's +# own status: reaching here means the runtime is installed and the brain is +# registered, which is the whole of what this script promises. +# The steps `ade setup` owns past that are enhancement, and several are +# documented non-fatal (the brain re-fetches the agent CLIs on every +# `ade serve`), so propagating its exit code failed whole Dockerfiles and CI +# provisioning runs over a flaky fetch. The summary tells the human what is left, +# and `ade setup` still exits non-zero when a person runs it directly. if [ "$interactive" -eq 1 ]; then - "$dest_dir/ade" "$@" { - const service = createSyncTunnelClientService({ - logger, - configStore: cloudRelayStore, - isAccountSignedIn: () => { - const status = accountAuthService.getStatus(); - return status.signedIn && Boolean(status.userId?.trim()); - }, - getAccountLease: async () => { - const status = accountAuthService.getStatus(); - const userId = status.signedIn ? status.userId?.trim() || null : null; - if (!userId) return null; - const token = (await accountAuthService.getAccessToken()).trim(); - const refreshed = accountAuthService.getStatus(); - return token && refreshed.signedIn && refreshed.userId?.trim() === userId - ? { userId, expiresAt: refreshed.expiresAt } - : null; - }, - onPublicationStateChanged: () => { - // Relay state changes are machine-level; without this nudge an idle - // machine emits no sync-status snapshot and the desktop relay banner - // never appears (or never clears). - syncService?.notifyRouteStateChanged(); - resolvedArgs.syncRuntime?.requestAccountMachinePublish?.(); - }, - // The analytics service is machine-scoped and shared, so capturing it in - // this one-per-machine factory closure is safe (unlike the listener - // accessors above, which is why those moved to attachHostListener). - captureAnalytics: (input) => { - productAnalyticsService.captureInternal(input); - }, - }); - return service; - }); - // Bind the listener OUTSIDE the factory. The client is cached one-per-machine - // and built by whichever runtime bootstrapped first, which is regularly a - // scope with no listener (headless one-shot, embedded fallback). Everything - // captured in that factory — the port accessor and the loopback retry hook — - // then pointed at null for the life of the process, so the bridge could never - // validate and Relay stayed fail-closed even though the listener was up. - // Attaching here means the runtime that actually owns the listener wins, - // whether or not it was the one that created the instance. - if (resolvedArgs.syncRuntime?.sharedSyncListener) { - syncTunnelClientService.attachHostListener(resolvedArgs.syncRuntime.sharedSyncListener); - } - // Only the runtime that holds the machine-wide sync host lease may register - // the relay tunnel. See relayTunnelAuthorityGate for why the old - // "has a listener" gate let secondary brains evict the real host. - const [{ createRelayTunnelAuthorityGate }, { holdsSyncHostSingleton, onSyncHostSingletonAuthorityChanged }] = - await Promise.all([ - import("./services/sync/relayTunnelAuthorityGate"), - import("./services/sync/syncHostSingleton"), - ]); - const relayTunnelGate = createRelayTunnelAuthorityGate({ - hostListener: resolvedArgs.syncRuntime?.sharedSyncListener ?? null, - tunnel: syncTunnelClientService, - holdsLease: holdsSyncHostSingleton, - subscribe: onSyncHostSingletonAuthorityChanged, + const { createMachineRelayTunnel } = await import("./services/sync/machineRelayTunnel"); + const { tunnel: syncTunnelClientService, gate: relayTunnelGate } = await createMachineRelayTunnel({ logger, + configStore: cloudRelayStore, + configPath: cloudRelayFilePath, + accountAuthService, + hostListener: resolvedArgs.syncRuntime?.sharedSyncListener ?? null, + onPublicationStateChanged: () => { + // Relay state changes are machine-level; without this nudge an idle + // machine emits no sync-status snapshot and the desktop relay banner + // never appears (or never clears). + syncService?.notifyRouteStateChanged(); + resolvedArgs.syncRuntime?.requestAccountMachinePublish?.(); + }, + captureAnalytics: (input) => { + productAnalyticsService.captureInternal(input); + }, }); let externalSessionsService: ReturnType | null = null; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f7b6bbf1a..0362919e7 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -138,6 +138,8 @@ import { cleanupLegacyBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService"; import type { SyncHostSingletonLease } from "./services/sync/syncHostSingleton"; +import type { SyncTunnelClientService } from "./services/sync/syncTunnelClientService"; +import type { RelayTunnelAuthorityGate } from "./services/sync/relayTunnelAuthorityGate"; import { shouldRejectDevelopmentEnvCredential, syncAccountAnalyticsIdentity, @@ -16306,6 +16308,7 @@ async function runServe( { buildRosterSnapshot, createForeignChatTranscriptResolver }, { createSyncCloudRelayStore }, { setSyncRuntimeRpcHandlerFactory }, + { buildProjectlessSyncSnapshot }, ] = await Promise.all([ import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), @@ -16317,6 +16320,7 @@ async function runServe( import("./services/sync/rosterBuilder"), import("./services/sync/syncCloudRelayStore"), import("./services/sync/syncPairedChannelService"), + import("./services/sync/projectlessSyncSnapshot"), ]); const layout = resolveMachineAdeLayout(); @@ -16696,21 +16700,52 @@ async function runServe( secretsDir: layout.secretsDir, }); // Same file the per-scope sync services read; another store instance is fine - // because every read reloads the file. + // because every read reloads the file. The path doubles as the machine-wide + // key for the shared relay tunnel client, so a projectless brain and a later + // project scope resolve to the SAME client instead of two racing dialers. + const machineCloudRelayFilePath = path.join(layout.secretsDir, "sync-cloud-relay.json"); const machineCloudRelayStore = createSyncCloudRelayStore({ - filePath: path.join(layout.secretsDir, "sync-cloud-relay.json"), + filePath: machineCloudRelayFilePath, }); let accountMachinePublisher: AccountMachinePublisherService | null = null; // Held only while this brain hosts phone sync WITHOUT a project scope (a // scope's sync service owns its own lease). Machine-exclusive subsystems // gate on holding one or the other. let brainSyncHostLease: SyncHostSingletonLease | null = null; + // Relay tunnel for that same projectless case. `createAdeRuntime` builds one + // per project scope; with no scope there is nobody to build it, which left a + // projectless machine reachable only on the LAN. + let brainSyncTunnelClient: SyncTunnelClientService | null = null; + let brainRelayTunnelGate: RelayTunnelAuthorityGate | null = null; let releaseAccountPublisherAuthoritySubscription: (() => void) | null = null; const getAccountDirectoryHealth = (): SyncAccountDirectoryHealth => accountMachinePublisher?.getPublisherHealth() ?? createSyncAccountDirectoryHealth( "sync_disabled", "Account-directory publishing has not started.", ); + // What this machine looks like when no project scope owns sync. Hosting is + // the projectless lease AND a bound shared listener; the builder reports the + // honest all-down shape otherwise. + const projectlessSyncSnapshot = (): SyncRoleSnapshot => { + const accountStatus = brainAccountAuthService.getStatus(); + const tunnelStatus = brainSyncTunnelClient?.getStatus() ?? null; + return buildProjectlessSyncSnapshot({ + secretsDir: layout.secretsDir, + listener: sharedSyncListener, + holdsSyncHostLease: brainSyncHostLease != null, + relay: { + accountSignedIn: accountStatus.signedIn && Boolean(accountStatus.userId?.trim()), + // Same gate the scoped path applies: never advertise a relay URL the + // tunnel has deliberately stopped dialing, or a phone that saved it + // keeps retrying a route only this machine knows is dead. + wssUrl: tunnelStatus?.accountLeaseValid && !tunnelStatus.controlSuppressed + ? machineCloudRelayStore.getRelayWssUrl() + : null, + status: tunnelStatus, + }, + accountDirectory: getAccountDirectoryHealth(), + }); + }; sharedSyncListener?.setFallbackConnectionHandler( createBrainProjectActionsSyncHandler({ logger: headlessProjectLogger, @@ -16789,6 +16824,7 @@ async function runServe( productAnalyticsService: brainProductAnalytics, accountAuthService: brainAccountAuthService, getAccountDirectoryHealth, + getProjectlessSyncSnapshot: projectlessSyncSnapshot, getRuntimeStatus: () => { const publishHealth = getAccountDirectoryHealth(); return { @@ -16805,6 +16841,44 @@ async function runServe( onShutdown: finish, }); clearSyncRuntimeRpcHandlerFactory = setSyncRuntimeRpcHandlerFactory(createHandler); + // A machine that hosts sync without a project must still be reachable off the + // LAN. `createAdeRuntime` builds the relay tunnel per project scope, so with + // no scope nothing ever dialled it and the target user for this path — a + // headless box or a fresh machine with no desktop app — was LAN-only. Built + // lazily, on the same event that takes the projectless lease. + const ensureProjectlessRelayTunnel = async (): Promise => { + const listener = sharedSyncListener; + if (!listener || brainRelayTunnelGate) return; + try { + const { createMachineRelayTunnel } = await import("./services/sync/machineRelayTunnel"); + const { tunnel, gate } = await createMachineRelayTunnel({ + logger: headlessProjectLogger, + configStore: machineCloudRelayStore, + configPath: machineCloudRelayFilePath, + accountAuthService: brainAccountAuthService, + hostListener: listener, + onPublicationStateChanged: () => { + // Relay reachability is part of what this machine publishes, so a + // control/bridge transition has to re-publish rather than wait out + // the heartbeat. + accountMachinePublisher?.requestPublishAfterCurrentAttempt(); + }, + captureAnalytics: (input) => { + brainProductAnalytics.captureInternal(input); + }, + }); + brainRelayTunnelGate = gate; + brainSyncTunnelClient = tunnel; + } catch (error) { + // Relay is an extra route, never a precondition for hosting sync. This + // runs inside the sync-host startup loop, which retries forever on + // failure, so letting a relay error escape would keep a perfectly good + // LAN host from ever finishing startup. + headlessProjectLogger.warn("sync.projectless_relay_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; const startSyncHost = async () => { let activeScope: Awaited< ReturnType["resolveActiveSyncHost"]> @@ -16831,11 +16905,18 @@ async function runServe( ), ); brainSyncHostLease.updatePort(listenerPort); + await ensureProjectlessRelayTunnel(); } else if (activeScope && brainSyncHostLease) { // A scope took over hosting and holds its own lease; drop the // projectless one so the lock file describes the real owner. brainSyncHostLease.dispose(); brainSyncHostLease = null; + // The scope's own runtime builds a gate over the SAME shared tunnel + // client, so hand ownership over instead of leaving two gates reacting to + // the same lease transitions. Disposing a gate never stops the tunnel. + brainRelayTunnelGate?.dispose(); + brainRelayTunnelGate = null; + brainSyncTunnelClient = null; } // A ProjectScope is a complete runtime (DB, search, chat, automation, // polling, PTY, and sync services), not a lightweight metadata cache. @@ -16848,6 +16929,9 @@ async function runServe( releaseAccountPublisherAuthoritySubscription = null; accountMachinePublisher?.dispose(); accountMachinePublisher = null; + brainRelayTunnelGate?.dispose(); + brainRelayTunnelGate = null; + brainSyncTunnelClient = null; brainSyncHostLease?.dispose(); brainSyncHostLease = null; // Before scopes detach (which clears the run map): best-effort Live @@ -16863,9 +16947,10 @@ async function runServe( } try { const { peekSharedSyncTunnelClientService } = await import("./services/sync/syncTunnelClientService"); - await peekSharedSyncTunnelClientService( - path.join(layout.secretsDir, "sync-cloud-relay.json"), - )?.dispose(); + // Same constant the tunnel was created under. That path IS the cache key + // for the machine-wide client, so a second spelling here would silently + // look up nothing and leak the tunnel past shutdown. + await peekSharedSyncTunnelClientService(machineCloudRelayFilePath)?.dispose(); } catch { // Best-effort tunnel teardown; the process exit closes sockets anyway. } @@ -17071,9 +17156,18 @@ async function runServe( logger: headlessProjectLogger, getSnapshot: async () => { const activeScope = await scopeRegistry.resolveActiveSyncHost(); - return await activeScope?.runtime.syncService?.getStatus({ + const scoped = await activeScope?.runtime.syncService?.getStatus({ includeTransferReadiness: false, }) ?? null; + if (scoped) return scoped; + // Hosting sync without a project is a supported state, not the + // absence of one. Falling through to null here is what kept a + // signed-in machine with an empty projects.json out of the account + // directory forever: the publisher reported `no_active_sync_scope` + // and returned before it ever made a request. Only report a snapshot + // while this brain actually holds the projectless lease — otherwise + // `no_active_sync_scope` remains the honest diagnosis. + return brainSyncHostLease ? projectlessSyncSnapshot() : null; }, getMachineKey: () => machineCloudRelayStore.getMachineIdentity().machineKey, directoryBaseUrl: () => process.env.ADE_ACCOUNT_DIRECTORY_URL?.trim() || undefined, diff --git a/apps/ade-cli/src/commands/setup.test.ts b/apps/ade-cli/src/commands/setup.test.ts index e1a549967..eb3f2d4b0 100644 --- a/apps/ade-cli/src/commands/setup.test.ts +++ b/apps/ade-cli/src/commands/setup.test.ts @@ -5,6 +5,7 @@ * `src/commands/` is already over its per-folder test-file budget and these are * one feature, not three. */ +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -21,6 +22,7 @@ import { } from "./setupRender"; import { assetUrl, + defaultDesktopAppPath, downloadWithResume, parseDesktopManifest, sha512Base64, @@ -176,6 +178,13 @@ describe("renderSummary", () => { expect(out).toContain("ade connect link this machine to your account"); }); + it("keeps the heading ASCII on a terminal that renders an em dash as mojibake", () => { + const failed = step({ state: "failed", detail: "didn't finish", nextAction: "ade connect" }); + expect(renderSummary([failed], totals, plain)).toContain("ADE installed - 1 step"); + expect(renderSummary([failed], totals, { ...plain, unicode: true })) + .toContain("ADE installed — 1 step"); + }); + it("reports elapsed time and bytes downloaded", () => { expect(renderSummary([step()], totals, plain)).toContain( "Installed in 2m 14s, 1.2 GB downloaded", @@ -270,10 +279,32 @@ describe("release asset helpers", () => { ); }); - it("produces the base64 SHA-512 electron-updater manifests carry, not hex", () => { - expect(sha512Base64(tempFile(""))).toBe( + it("produces the base64 SHA-512 electron-updater manifests carry, not hex", async () => { + // Streamed rather than read into one buffer: the real input is ~1 GB. + expect(await sha512Base64(tempFile(""))).toBe( "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==", ); + const body = "DESKTOP-INSTALLER"; + expect(await sha512Base64(tempFile(body))).toBe( + createHash("sha512").update(body).digest("base64"), + ); + }); +}); + +describe("defaultDesktopAppPath", () => { + it("follows the package channel instead of hardcoding the stable name", () => { + // Hardcoding `ADE` made the post-install launch a silent no-op on beta. + expect( + defaultDesktopAppPath("win32", { + LOCALAPPDATA: path.join("C:", "Users", "a", "AppData", "Local"), + ADE_PACKAGE_CHANNEL: "beta", + }), + ).toBe( + path.join("C:", "Users", "a", "AppData", "Local", "Programs", "ADE Beta", "ADE Beta.exe"), + ); + expect(defaultDesktopAppPath("darwin", { ADE_PACKAGE_CHANNEL: "alpha" })) + .toBe("/Applications/ADE Alpha.app"); + expect(defaultDesktopAppPath("darwin", {})).toBe("/Applications/ADE.app"); }); }); @@ -333,6 +364,69 @@ describe("downloadWithResume", () => { expect(fs.readFileSync(destination, "utf8")).toBe("OK"); }); + it("retries a connection that goes silent instead of hanging on it forever", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); + const destination = path.join(dir, "out.bin"); + let attempts = 0; + const fetchImpl = vi.fn((_url: string, init?: RequestInit) => { + attempts += 1; + // A server that accepts and then sends nothing. Without an idle deadline + // this promise never settles, the step line freezes, and the retry below + // never gets a turn. + if (attempts === 1) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }); + } + return Promise.resolve(response("OK", 200, { "content-length": "2" })); + }) as unknown as typeof fetch; + + await downloadWithResume("https://example.invalid/ADE.exe", destination, () => {}, { + fetchImpl, + idleTimeoutMs: 20, + }); + expect(attempts).toBe(2); + expect(fs.readFileSync(destination, "utf8")).toBe("OK"); + }); + + it("treats the caller's own abort as terminal, unlike the idle deadline", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); + const destination = path.join(dir, "out.bin"); + const controller = new AbortController(); + controller.abort(); + let attempts = 0; + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + attempts += 1; + if (init?.signal?.aborted) throw new Error("aborted by caller"); + return response("OK", 200, { "content-length": "2" }); + }) as unknown as typeof fetch; + + await expect( + downloadWithResume("https://example.invalid/ADE.exe", destination, () => {}, { + fetchImpl, + signal: controller.signal, + }), + ).rejects.toThrow("aborted by caller"); + expect(attempts).toBe(1); + }); + + it("accepts the 416 a complete cache file gets, rather than burning its retries", async () => { + const destination = tempFile("COMPLETE"); + const fetchImpl = vi.fn(async () => + new Response(null, { status: 416 }) + ) as unknown as typeof fetch; + + const total = await downloadWithResume( + "https://example.invalid/ADE.exe", + destination, + () => {}, + { fetchImpl }, + ); + expect(total).toBe(8); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(fs.readFileSync(destination, "utf8")).toBe("COMPLETE"); + }); + it("reports progress with a total when content-length is known", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-test-")); const destination = path.join(dir, "out.bin"); @@ -542,6 +636,180 @@ describe("runSetupCommand", () => { expect(fetchImpl).toHaveBeenCalledOnce(); expect(launchDesktop).toHaveBeenCalledOnce(); }); + + it("keeps the artifact for the next run and clears it only once the install works", async () => { + // The README promises the 1 GB download resumes from a partial file, which + // is only true across runs -- and the run that gets interrupted is exactly + // the one that never reaches its own cleanup. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-home-")); + const body = "DESKTOP-INSTALLER"; + const digest = createHash("sha512").update(body).digest("base64"); + const manifest = `version: 1.2.54 +files: + - url: ADE-1.2.54-win-x64.exe + sha512: ${digest} +path: ADE-1.2.54-win-x64.exe +`; + const cached = path.join(adeHome, "cache", "desktop", "ADE-1.2.54-win-x64.exe"); + + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith("latest.yml")) return new Response(manifest); + // GitHub answers a range request for a file that is already whole with a + // 416, which is what the second run sends. + const headers = init?.headers as Record | undefined; + if (headers?.Range) return new Response(null, { status: 416 }); + return new Response(body, { + status: 200, + headers: { "content-length": String(body.length) }, + }); + }) as unknown as typeof fetch; + + const interrupted = await runSetupCommand(["--continue"], deps({ + platform: "win32", + env: { ADE_HOME: adeHome }, + reporter: reporter([]), + fetchImpl, + installDesktop: async () => { + throw new Error("the desktop installer exited with code 1"); + }, + })); + expect(interrupted.steps.find((s) => s.id === "desktop")?.state).toBe("failed"); + expect(fs.existsSync(cached)).toBe(true); + + const finished = await runSetupCommand(["--continue"], deps({ + platform: "win32", + env: { ADE_HOME: adeHome }, + reporter: reporter([]), + fetchImpl, + installDesktop: async () => ({ appPath: null, detail: "installed" }), + })); + expect(finished.steps.find((s) => s.id === "desktop")?.state).toBe("ok"); + expect(fs.existsSync(cached)).toBe(false); + }); + + it("sweeps stale cache entries without touching the one it is resuming", async () => { + // Nothing else collects ~/.ade/cache/desktop -- `ade tools gc` covers the + // tools root and the storage dashboard reads /.ade/cache -- so a + // partial from an abandoned run or a superseded release lives forever. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-home-")); + const body = "DESKTOP-INSTALLER"; + const digest = createHash("sha512").update(body).digest("base64"); + const manifest = `version: 1.2.54 +files: + - url: ADE-1.2.54-win-x64.exe + sha512: ${digest} +path: ADE-1.2.54-win-x64.exe +`; + const cacheDir = path.join(adeHome, "cache", "desktop"); + fs.mkdirSync(cacheDir, { recursive: true }); + const stale = path.join(cacheDir, "ADE-1.2.53-win-x64.exe"); + fs.writeFileSync(stale, "ABANDONED-PARTIAL"); + // The half of the current artifact a Ctrl-C left behind: it must survive the + // sweep, or the resume this cache exists for can never happen. + fs.writeFileSync(path.join(cacheDir, "ADE-1.2.54-win-x64.exe"), body.slice(0, 8)); + + let ranged = false; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith("latest.yml")) return new Response(manifest); + const headers = init?.headers as Record | undefined; + if (!headers?.Range) return new Response(body); + ranged = true; + return new Response(body.slice(8), { + status: 206, + headers: { "content-length": String(body.length - 8) }, + }); + }) as unknown as typeof fetch; + + const result = await runSetupCommand(["--continue"], deps({ + platform: "win32", + env: { ADE_HOME: adeHome }, + reporter: reporter([]), + fetchImpl, + installDesktop: async () => ({ appPath: null, detail: "installed" }), + })); + + expect(result.steps.find((s) => s.id === "desktop")?.state).toBe("ok"); + expect(ranged).toBe(true); + expect(fs.existsSync(stale)).toBe(false); + }); + + it("does not sweep away its own download lock", async () => { + // Regression: the sweep deletes every cache entry except the artifact being + // resumed, and the concurrency lock lives in that same directory. Removing + // it mid-download would let a second `ade setup` acquire it and write to the + // same file. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-setup-home-")); + const body = "DESKTOP-INSTALLER"; + const digest = createHash("sha512").update(body).digest("base64"); + const cacheDir = path.join(adeHome, "cache", "desktop"); + fs.mkdirSync(cacheDir, { recursive: true }); + + const fetchImpl = vi.fn(async (url: string) => + String(url).endsWith("latest.yml") + ? new Response( + `version: 1.2.54\nfiles:\n - url: ADE-1.2.54-win-x64.exe\n sha512: ${digest}\n`, + ) + : new Response(body) + ) as unknown as typeof fetch; + + let lockDuringInstall: boolean | null = null; + const result = await runSetupCommand(["--continue"], deps({ + platform: "win32", + env: { ADE_HOME: adeHome }, + reporter: reporter([]), + fetchImpl, + installDesktop: async () => { + // Sampled while the lock is still held -- it is released in a `finally` + // after this returns. + lockDuringInstall = fs.existsSync(path.join(cacheDir, ".download.lock")); + return { appPath: null, detail: "installed" }; + }, + })); + + expect(result.steps.find((s) => s.id === "desktop")?.state).toBe("ok"); + expect(lockDuringInstall).toBe(true); + }); + + it("surfaces a failed check even when the user declined sign-in", async () => { + // The account step is `skipped`, so the old guard (`state === "ok"`) left a + // dead brain entirely out of the summary and still exited 0. + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + ask: async () => 1, + getAccountStatus: async () => ({ signedIn: false, identity: null }), + verify: async () => ({ + ok: false, + detail: "the ADE brain is not running", + nextAction: "ade brain start", + }), + })); + + expect(result.ok).toBe(false); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("failed"); + expect(account?.detail).toBe("the ADE brain is not running"); + }); + + it("leaves a declined sign-in as a choice, not a failure", async () => { + // Verification can only report the decline back here, and the skipped step + // already prints the same recovery command. + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter([]), + ask: async () => 1, + getAccountStatus: async () => ({ signedIn: false, identity: null }), + verify: async () => ({ + ok: false, + detail: "sign-in didn't finish", + nextAction: "ade connect", + }), + })); + + expect(result.ok).toBe(true); + expect(result.verified).toBe(false); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("skipped"); + expect(account?.detail).toBe("not linked"); + }); }); // The bug that made this whole pass necessary: the installers registered the @@ -567,24 +835,81 @@ describe("installer brain registration (regression)", () => { } }); +// `irm | iex` and `curl | sh` are what a Dockerfile, a CI provisioning step and +// every config-management run invoke, and they trust the exit code. `ade setup` +// documents its agent-CLI step as non-fatal, so a flaky fetch there must not +// fail an install whose runtime and brain both landed. +describe("installer exit codes (regression)", () => { + it("install-runtime.ps1 does not propagate ade setup's exit code", () => { + const source = fs.readFileSync(path.join(scriptsDir, "install-runtime.ps1"), "utf8"); + expect(source).not.toMatch(/exit\s+\$setupExit/); + expect(source).toMatch(/^\s*exit 0\s*$/m); + }); + + it("install-runtime.sh does not propagate ade setup's exit code", () => { + const source = fs.readFileSync(path.join(scriptsDir, "install-runtime.sh"), "utf8"); + expect(source).not.toMatch(/exit\s+"\$setup_status"/); + expect(source).toMatch(/^\s*exit 0\s*$/m); + }); +}); + +describe("installer shell portability (regression)", () => { + it("install-runtime.sh probes for --show-progress rather than assuming GNU wget", () => { + // busybox wget (Alpine, most slim images) rejects the flag outright, and + // this is the required runtime download path, not a best-effort upsell. + const source = fs.readFileSync(path.join(scriptsDir, "install-runtime.sh"), "utf8"); + expect(source).toMatch(/wget --help .*grep -q -- '--show-progress'/); + }); + + it("install-runtime.ps1 never assigns PowerShell's automatic $input", () => { + const source = fs.readFileSync(path.join(scriptsDir, "install-runtime.ps1"), "utf8"); + expect(source).not.toMatch(/\$input\s*=[^=]/); + }); +}); + // The publisher's skipReason strings are internal diagnostics. The desktop // Connections pane concatenates them into user-facing copy verbatim, which is // how "No active sync scope is available." reached a real user's screen. describe("describeUnpublishedMachine", () => { - it("translates no_active_sync_scope into the action that clears it", async () => { + it("no longer tells the user to open a project -- that case now publishes", async () => { const { describeUnpublishedMachine } = await import("./setup"); const described = describeUnpublishedMachine( "no_active_sync_scope", "No active sync scope is available.", ); + // A projectless brain publishes on its own now, so the only way to reach + // this state is another ADE process owning the machine sync-host lease. expect(described.detail).not.toContain("sync scope"); - expect(described.nextAction).toBe("open a project in ADE to finish linking this machine"); + expect(described.detail).not.toContain("open a project"); + expect(described.detail).toContain("another ADE app on this computer"); }); - it("keeps the reason visible for states it has no specific advice for", async () => { + it("never renders the publisher's skipReason, for any state", async () => { const { describeUnpublishedMachine } = await import("./setup"); + // The whole point: skipReason is an internal diagnostic. Printing it tells + // the user a fault occurred and nothing about clearing it. expect(describeUnpublishedMachine("http_error", "directory returned 503").detail) - .toContain("directory returned 503"); - expect(describeUnpublishedMachine("http_error", null).detail).toContain("http error"); + .not.toContain("directory returned 503"); + expect(describeUnpublishedMachine("http_error", null).detail) + .toContain("can't reach your ADE account"); + }); + + it("hands an unrecognised state from a newer runtime to ade doctor", async () => { + const { describeUnpublishedMachine } = await import("./setup"); + const described = describeUnpublishedMachine("some_future_state", "raw internal detail"); + expect(described.detail).not.toContain("raw internal detail"); + expect(described.detail).not.toContain("some_future_state"); + expect(described.nextAction).toBe("ade doctor"); + }); + + it("matches the desktop pane, because both read one shared table", async () => { + const { describeUnpublishedMachine } = await import("./setup"); + const { describeUnpublishedAccountDirectory } = await import( + "../../../desktop/src/shared/types/sync" + ); + for (const state of ["no_active_sync_scope", "account_signed_out", "not_host"] as const) { + expect(describeUnpublishedMachine(state).detail) + .toBe(describeUnpublishedAccountDirectory(state).summary); + } }); }); diff --git a/apps/ade-cli/src/commands/setup.ts b/apps/ade-cli/src/commands/setup.ts index e90991fbd..0def961e4 100644 --- a/apps/ade-cli/src/commands/setup.ts +++ b/apps/ade-cli/src/commands/setup.ts @@ -36,8 +36,12 @@ import { DEFAULT_ADE_RELEASE_REPO, type DesktopManifestEntry, } from "./setupDesktop"; +import { resolveMachineAdeDir } from "../services/projects/machineLayout"; +import { + describeUnpublishedAccountDirectory, + isSyncAccountDirectoryState, +} from "../../../desktop/src/shared/types/sync"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; export class SetupUsageError extends Error {} @@ -45,29 +49,25 @@ export class SetupUsageError extends Error {} /** * Turn an account-directory publisher state into copy a person can act on. * - * The publisher's `skipReason` strings are internal diagnostics ("No active - * sync scope is available."). Surfacing them verbatim -- which is what the - * desktop Connections pane does today -- tells a user that a fault occurred - * and nothing whatsoever about how to clear it. + * The per-state copy is shared with the desktop Connections pane + * (`describeUnpublishedAccountDirectory`), because the two previously kept + * hand-mirrored tables that had already drifted apart: the pane covered every + * state while this function covered one and printed the publisher's raw + * `skipReason` for the rest -- the exact behaviour its own comment condemned. + * + * `skipReason` is now never rendered. An unrecognised state means a newer + * runtime than this CLI, so it gets the honest generic line and `ade doctor`, + * which is the command that can actually show the detail. */ export function describeUnpublishedMachine( state: string, - skipReason: string | null, + _skipReason?: string | null, ): { detail: string; nextAction: string } { - if (state === "no_active_sync_scope") { - return { - detail: "signed in, but this machine isn't in your account yet", - // The publisher's only snapshot source is the active project's sync host, - // so with no project registered there is nothing for it to publish. - nextAction: "open a project in ADE to finish linking this machine", - }; + if (!isSyncAccountDirectoryState(state)) { + return { detail: "not published to your account", nextAction: "ade doctor" }; } - return { - detail: skipReason?.trim() - ? `not published to your account (${skipReason.trim()})` - : `not published to your account (${state.replaceAll("_", " ")})`, - nextAction: "ade connect", - }; + const { summary, nextAction } = describeUnpublishedAccountDirectory(state); + return { detail: summary, nextAction: nextAction ?? "ade doctor" }; } export type SetupOptions = { @@ -335,14 +335,23 @@ export async function runSetupCommand( // --- verification ---------------------------------------------------------- // "The files copied" is not the same claim as "it works". This is the check // that would have caught the install reporting success after sign-in failed. + // + // It has to reach the summary whatever the account step decided, or an install + // onto a machine whose brain is dead still heads its summary "ADE is ready". + // The one exception is the user declining sign-in: the check then only reports + // the decline back, and the skipped step already says so with the same + // recovery command, so failing it would punish a legitimate choice. let verified = false; try { const result = await deps.verify(); verified = result.ok; - if (!result.ok && accountStep.state === "ok") { + const nextAction = result.nextAction ?? "ade connect"; + const alreadyOffered = accountStep.state === "skipped" && + accountStep.nextAction === nextAction; + if (!result.ok && accountStep.state !== "failed" && !alreadyOffered) { accountStep.state = "failed"; accountStep.detail = result.detail; - accountStep.nextAction = result.nextAction ?? "ade connect"; + accountStep.nextAction = nextAction; } } catch { verified = false; @@ -433,6 +442,9 @@ async function runAccountStep(args: { step.nextAction = "ade connect"; } +/** The manifest is a few kilobytes; anything slower than this is a dead feed. */ +const MANIFEST_TIMEOUT_MS = 30_000; + /** Returns the bytes downloaded, so the summary total stays honest. */ async function runDesktopStep(args: { step: SetupStep; @@ -454,9 +466,18 @@ async function runDesktopStep(args: { } const manifestName = manifestNameForPlatform(platform); - const manifestResponse = await fetchImpl( - assetUrl(manifestName, options.repo, options.releaseVersion), - ); + let manifestResponse: Response; + try { + manifestResponse = await fetchImpl( + assetUrl(manifestName, options.repo, options.releaseVersion), + // A release feed that accepts the connection and then stalls would + // otherwise freeze the installer on this step line with no way through to + // the summary. Failing the desktop step is recoverable; a hang is not. + { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) }, + ); + } catch (error) { + throw new Error(`could not read ${manifestName} (${describeError(error)})`); + } if (!manifestResponse.ok) { throw new Error(`could not read ${manifestName}`); } @@ -519,12 +540,38 @@ async function downloadAndInstallDesktop(args: { deps: SetupDeps; }): Promise { const { step, entry, options, platform, env, reporter, deps } = args; - const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-dl-")); - const artifact = path.join(stageDir, path.basename(entry.name)); + // A stable path, not a per-run temp dir: resuming a 1 GB artifact is only + // worth anything across runs, and the interrupted run is precisely the one + // that does not get to clean up after itself. The manifest's asset name + // carries the release version, so a new release never resumes onto an old + // partial file. + const cacheDir = path.join(resolveMachineAdeDir(env), "cache", "desktop"); + fs.mkdirSync(cacheDir, { recursive: true }); + const artifact = path.join(cacheDir, path.basename(entry.name)); + + // A stable path is shared state, so two `ade setup` runs would otherwise open + // the same file and interleave writes -- both then fail the checksum and both + // delete it, where two isolated temp dirs had both succeeded. Re-running the + // installer while the first is still going is exactly the case resume exists + // for, so it has to be the case that works. Same lock primitive the agent-CLI + // cache uses, including its Windows EPERM/EACCES/EBUSY contention handling. + const { acquireToolLock } = await import("../services/tools/lock"); + const lock = await acquireToolLock({ + lockPath: path.join(cacheDir, ".download.lock"), + onWait: () => { + reporter.updateStep(step, { + fraction: null, + item: "waiting for another ADE install", + }); + }, + }); + const release = lock.kind === "acquired" ? lock.release : null; - step.state = "active"; - reporter.beginStep(step); try { + removeOtherCachedDesktopArtifacts(cacheDir, path.basename(artifact)); + + step.state = "active"; + reporter.beginStep(step); const bytes = await downloadWithResume( assetUrl(entry.name, options.repo, options.releaseVersion), artifact, @@ -541,7 +588,11 @@ async function downloadAndInstallDesktop(args: { { fetchImpl: deps.fetchImpl }, ); - if (sha512Base64(artifact) !== entry.sha512) { + if (await sha512Base64(artifact) !== entry.sha512) { + // The one failure a kept file must not survive. A truncated-then-completed + // or otherwise wrong artifact would be resumed as "already whole" on every + // later run, so the install could never recover on its own. + removeCachedDesktopArtifact(artifact); throw new Error(`checksum mismatch for ${path.basename(entry.name)}`); } @@ -550,6 +601,10 @@ async function downloadAndInstallDesktop(args: { platform, env, ); + // Only now. Every earlier exit -- a dropped connection, a Ctrl-C, a failed + // installer -- deliberately leaves the file so the next run resumes it. + removeCachedDesktopArtifact(artifact); + const appPath = installed.appPath ?? defaultDesktopAppPath(platform, env); step.state = "ok"; step.location = appPath ?? undefined; @@ -563,17 +618,51 @@ async function downloadAndInstallDesktop(args: { } return bytes; } finally { - // Windows only unlinks a name once every handle closes, and the NSIS - // installer can still hold the .exe briefly after spawnSync returns -- - // `force` swallows ENOENT but not EBUSY/EPERM. Retry, then give up quietly: - // a leftover temp file must never turn a successful install into a failure. - try { - fs.rmSync(stageDir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }); - } catch {} + await release?.(); } } + +function removeCachedDesktopArtifact(artifact: string): void { + // Windows only unlinks a name once every handle closes, and the NSIS + // installer can still hold the .exe briefly after spawnSync returns -- + // `force` swallows ENOENT but not EBUSY/EPERM. Retry, then give up quietly: + // a leftover cache file must never turn a successful install into a failure. + // `recursive` is what arms that retry at all: Node ignores maxRetries and + // retryDelay without it, so this used to give up on the very first EBUSY and + // strand a gigabyte forever. + try { + fs.rmSync(artifact, { + force: true, + recursive: true, + maxRetries: 10, + retryDelay: 100, + }); + } catch {} +} + +/** + * Sweep every cache entry except the one this run is about to resume. + * + * Nothing else collects `/cache/desktop`: `ade tools gc` covers the + * tools root and the storage dashboard enumerates `/.ade/cache`, so a + * partial download abandoned by a Ctrl-C -- or left behind by a release that has + * since been superseded -- is invisible and permanent. Doing it here, on the one + * path that writes the directory, keeps both cases in a single place. + */ +function removeOtherCachedDesktopArtifacts(cacheDir: string, keep: string): void { + // `===` is the wrong comparison for a filename on Windows and macOS: the + // filesystem is case-insensitive but the string is not, so a manifest whose + // asset casing changed between runs would leave the on-disk spelling + // unmatched and this sweep would delete the very partial it meant to resume. + // Linux stays case-sensitive, so this must not fold unconditionally. + const foldsCase = process.platform === "win32" || process.platform === "darwin"; + const sameName = (name: string): boolean => + foldsCase ? name.toLowerCase() === keep.toLowerCase() : name === keep; + try { + for (const name of fs.readdirSync(cacheDir)) { + // The lock file is this directory's own bookkeeping, not a stale artifact. + if (name === ".download.lock" || sameName(name)) continue; + removeCachedDesktopArtifact(path.join(cacheDir, name)); + } + } catch {} +} diff --git a/apps/ade-cli/src/commands/setupDesktop.ts b/apps/ade-cli/src/commands/setupDesktop.ts index 80f38306e..19915a192 100644 --- a/apps/ade-cli/src/commands/setupDesktop.ts +++ b/apps/ade-cli/src/commands/setupDesktop.ts @@ -16,10 +16,12 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pipeline } from "node:stream/promises"; import { DEFAULT_ADE_RELEASE_REPO, releaseAssetUrl, } from "../lib/releaseAssets"; +import { resolveDefaultDesktopAppName } from "./doctor"; export { DEFAULT_ADE_RELEASE_REPO }; @@ -91,14 +93,33 @@ export function parseDesktopManifest( return null; } -export function sha512Base64(filePath: string): string { +/** + * Streamed, not buffered: the file this hashes is the ~1 GB desktop installer, + * and a single allocation that size is an out-of-memory kill mid-install on a + * small VM or container -- one that grows with every release. The shell code + * this replaced streamed it too (`[IO.File]::OpenRead` -> `ComputeHash`). + */ +export async function sha512Base64(filePath: string): Promise { const hash = createHash("sha512"); - hash.update(fs.readFileSync(filePath)); + // Fed by hand rather than piped into `hash` as a stream: ending its writable + // side finalizes the digest internally, and `hash.digest()` would then throw. + await pipeline(fs.createReadStream(filePath), async (source) => { + for await (const chunk of source) hash.update(chunk as Uint8Array); + }); return hash.digest("base64"); } const RETRYABLE_ATTEMPTS = 3; +/** + * How long the transfer may go completely silent before we call it dead. + * + * Deliberately an idle deadline and not a total one: a 1 GB artifact on a slow + * line legitimately takes an hour, and killing that mid-flight would be a worse + * bug than the hang it fixes. + */ +const IDLE_TIMEOUT_MS = 60_000; + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -118,9 +139,12 @@ export async function downloadWithResume( deps: { fetchImpl?: typeof fetch; signal?: AbortSignal; + /** Test seam for the idle deadline; see IDLE_TIMEOUT_MS. */ + idleTimeoutMs?: number; } = {}, ): Promise { const fetchImpl = deps.fetchImpl ?? fetch; + const idleTimeoutMs = deps.idleTimeoutMs ?? IDLE_TIMEOUT_MS; let lastError: unknown = null; for (let attempt = 0; attempt < RETRYABLE_ATTEMPTS; attempt += 1) { @@ -130,8 +154,36 @@ export async function downloadWithResume( const headers: Record = {}; if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`; + // A socket that is accepted and then goes quiet never rejects on its own, + // so without this the step line freezes forever and the retry loop below + // never gets a turn -- the same hang class as a read on a dead stdin. + const attemptAbort = new AbortController(); + let idleTimer: ReturnType | null = null; + const armIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => attemptAbort.abort(), idleTimeoutMs); + }; + const onCallerAbort = () => attemptAbort.abort(); + deps.signal?.addEventListener("abort", onCallerAbort, { once: true }); + if (deps.signal?.aborted) attemptAbort.abort(); + armIdleTimer(); + try { - const response = await fetchImpl(url, { headers, signal: deps.signal }); + const response = await fetchImpl(url, { + headers, + signal: attemptAbort.signal, + }); + // A file that is already whole answers a `bytes=-` range with 416, + // not 206. Report it rather than burning three attempts on it: the + // caller's SHA-512 check, not the byte count, decides whether it is good. + if (response.status === 416 && resumeFrom > 0) { + onProgress({ + receivedBytes: resumeFrom, + totalBytes: resumeFrom, + bytesPerSecond: null, + }); + return resumeFrom; + } if (!response.ok) { throw new Error(`download failed with HTTP ${response.status}`); } @@ -155,6 +207,7 @@ export async function downloadWithResume( for await (const chunk of response.body as unknown as AsyncIterable) { fs.writeSync(handle, chunk); received += chunk.byteLength; + armIdleTimer(); // Throttle: a 1 GB download emits tens of thousands of chunks and the // renderer would spend more time drawing than the socket does reading. const now = Date.now(); @@ -176,10 +229,16 @@ export async function downloadWithResume( return received; } catch (error) { lastError = error; - if ((error as { name?: string })?.name === "AbortError") throw error; + // Only the caller's own abort is terminal. The idle deadline aborts the + // same way, but it means "this connection died", which is exactly what + // the retry loop exists for -- and the partial file makes it cheap. + if (deps.signal?.aborted) throw error; if (attempt < RETRYABLE_ATTEMPTS - 1) { await sleep(500 * 2 ** attempt); } + } finally { + if (idleTimer) clearTimeout(idleTimer); + deps.signal?.removeEventListener("abort", onCallerAbort); } } throw lastError instanceof Error ? lastError : new Error(String(lastError)); @@ -191,23 +250,31 @@ export type DesktopInstallResult = { detail: string; }; -/** Where the installed app lives, for the "already installed" short-circuit. */ +/** + * Where the installed app lives, for the "already installed" short-circuit. + * + * The name is channel-dependent (`ADE Beta.exe` under `Programs\ADE Beta\`), so + * it comes from the one helper that already resolves it -- hardcoding `ADE` here + * made the post-install launch a silent no-op on every non-stable channel. + */ export function defaultDesktopAppPath( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = process.env, ): string | null { + const name = resolveDefaultDesktopAppName(env); if (platform === "win32") { const local = env.LOCALAPPDATA; - return local ? path.join(local, "Programs", "ADE", "ADE.exe") : null; + return local ? path.join(local, "Programs", name, `${name}.exe`) : null; } - if (platform === "darwin") return "/Applications/ADE.app"; + if (platform === "darwin") return `/Applications/${name}.app`; return null; } /** * Windows: the NSIS installer is built oneClick:false + perMachine:false + * allowElevation:false, so `/S` is a silent per-user install with no UAC prompt. - * macOS: expand the zip and promote ADE.app, moving any existing copy aside + * macOS: expand the zip and promote the bundle named for this channel (Stable, + * Beta and Alpha ship different app names), moving any existing copy aside * first so a failed promotion leaves the user's working app in place. */ export async function installDesktopArtifact( @@ -234,6 +301,7 @@ export async function installDesktopArtifact( } if (platform === "darwin") { + const bundleName = `${resolveDefaultDesktopAppName(env)}.app`; const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-")); const extract = spawnSync("ditto", ["-x", "-k", artifactPath, stage], { stdio: "ignore", @@ -242,21 +310,29 @@ export async function installDesktopArtifact( if (extract.status !== 0) { throw new Error("could not expand the desktop app archive"); } - const staged = path.join(stage, "ADE.app"); + const staged = path.join(stage, bundleName); if (!fs.existsSync(staged)) { - throw new Error("desktop app archive did not contain ADE.app"); + throw new Error(`desktop app archive did not contain ${bundleName}`); } const appsDir = canWrite("/Applications") ? "/Applications" : path.join(os.homedir(), "Applications"); fs.mkdirSync(appsDir, { recursive: true }); - const target = path.join(appsDir, "ADE.app"); + const target = path.join(appsDir, bundleName); const backup = `${target}.previous`; fs.rmSync(backup, { recursive: true, force: true }); if (fs.existsSync(target)) fs.renameSync(target, backup); try { - fs.renameSync(staged, target); + movePath(staged, target); } catch (error) { + // A cross-device copy that died halfway leaves a partial bundle the + // backup cannot be renamed back over, so clear the way first -- but never + // let clearing it abort the restore. The user's working app has already + // been renamed to `.previous` by this point, so a throw here would leave + // them with no app at all, which is worse than every failure it replaces. + try { + fs.rmSync(target, { recursive: true, force: true }); + } catch {} if (fs.existsSync(backup)) fs.renameSync(backup, target); throw error; } @@ -268,6 +344,31 @@ export async function installDesktopArtifact( return { appPath: null, detail: "not available on this platform" }; } +/** + * `mv`, including across volumes. + * + * The staged bundle is under `os.tmpdir()` and the target is `/Applications`, so + * a `TMPDIR` on another volume makes `rename` fail with EXDEV. The shell code + * this replaced used `mv`, which falls back to a copy; without that fallback the + * install fails safe but fails where it used to work. + */ +function movePath(from: string, to: string): void { + try { + fs.renameSync(from, to); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error; + // verbatimSymlinks: an .app bundle's Frameworks are relative symlinks, and + // dereferencing them would both bloat the copy and break the signature. + fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true }); + // The copy succeeded, so the move is done. Failing to tidy the staging tree + // (a file still held open under TMPDIR) must not throw: the caller's catch + // would read that as a failed install and roll a good app back. + try { + fs.rmSync(from, { recursive: true, force: true }); + } catch {} + } +} + function canWrite(target: string): boolean { try { fs.accessSync(target, fs.constants.W_OK); diff --git a/apps/ade-cli/src/commands/setupRender.ts b/apps/ade-cli/src/commands/setupRender.ts index ebf018bd8..2f7f8220a 100644 --- a/apps/ade-cli/src/commands/setupRender.ts +++ b/apps/ade-cli/src/commands/setupRender.ts @@ -253,9 +253,10 @@ export function renderSummary( caps: TerminalCapabilities, ): string { const failed = steps.filter((step) => step.state === "failed"); + const dash = caps.unicode ? "—" : "-"; const heading = failed.length === 0 ? "ADE is ready" - : `ADE installed — ${failed.length} step${failed.length === 1 ? "" : "s"} need${failed.length === 1 ? "s" : ""} you`; + : `ADE installed ${dash} ${failed.length} step${failed.length === 1 ? "" : "s"} need${failed.length === 1 ? "s" : ""} you`; const lines: string[] = ["", ` ${heading}`, ""]; for (const step of steps) { diff --git a/apps/ade-cli/src/lib/nodeWarnings.test.ts b/apps/ade-cli/src/lib/nodeWarnings.test.ts index bbabe7258..9a46d0936 100644 --- a/apps/ade-cli/src/lib/nodeWarnings.test.ts +++ b/apps/ade-cli/src/lib/nodeWarnings.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { isSuppressedNodeWarning } from "./nodeWarnings"; +import { + installNodeWarningFilter, + isSuppressedNodeWarning, + resetNodeWarningFilterForTests, +} from "./nodeWarnings"; describe("isSuppressedNodeWarning", () => { it("suppresses the node:sqlite experimental notice users saw on every command", () => { @@ -42,3 +46,18 @@ describe("isSuppressedNodeWarning", () => { expect(isSuppressedNodeWarning("something broke")).toBe(false); }); }); + +describe("resetNodeWarningFilterForTests", () => { + it("puts the original emitter back so a second install cannot double-wrap", () => { + // The module self-installs on import, so the first reset is what gets this + // process back to Node's own emitter. + resetNodeWarningFilterForTests(); + const unwrapped = process.emitWarning; + + installNodeWarningFilter({}); + expect(process.emitWarning).not.toBe(unwrapped); + + resetNodeWarningFilterForTests(); + expect(process.emitWarning).toBe(unwrapped); + }); +}); diff --git a/apps/ade-cli/src/lib/nodeWarnings.ts b/apps/ade-cli/src/lib/nodeWarnings.ts index 18aeb3f77..d05f75f22 100644 --- a/apps/ade-cli/src/lib/nodeWarnings.ts +++ b/apps/ade-cli/src/lib/nodeWarnings.ts @@ -18,6 +18,8 @@ const SHOW_WARNINGS_ENV = "ADE_SHOW_NODE_WARNINGS"; let installed = false; +/** The unwrapped emitter, kept so a reset can put the process back as it was. */ +let originalEmitWarning: typeof process.emitWarning | null = null; function warningType( warning: string | Error, @@ -66,6 +68,7 @@ export function installNodeWarningFilter( if (env[SHOW_WARNINGS_ENV] === "1") return; installed = true; + originalEmitWarning = process.emitWarning; const original = process.emitWarning.bind(process); process.emitWarning = (( warning: string | Error, @@ -79,6 +82,11 @@ export function installNodeWarningFilter( /** Test-only: lets a suite install the filter against a fresh module state. */ export function resetNodeWarningFilterForTests(): void { installed = false; + // Clearing the flag alone is not a reset: the wrapper stays on `process` and + // the next install wraps it again, so the chain grows by one frame per reset + // and every warning is filtered as many times as the suite has reset. + if (originalEmitWarning) process.emitWarning = originalEmitWarning; + originalEmitWarning = null; } // Self-installing on import, deliberately. `node:sqlite` emits its warning the diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index bb3352d03..489719df3 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -13,7 +13,11 @@ import { import * as gitModule from "../../desktop/src/main/services/git/git"; import { ProjectRegistry } from "./services/projects/projectRegistry"; import { ProjectScopeRegistry } from "./services/projects/projectScope"; -import type { SyncRoleSnapshot } from "../../desktop/src/shared/types"; +import { buildProjectlessSyncSnapshot } from "./services/sync/projectlessSyncSnapshot"; +import { + createSyncAccountDirectoryHealth, + type SyncRoleSnapshot, +} from "../../desktop/src/shared/types"; import { RUNTIME_COMPAT_LEVEL } from "../../desktop/src/shared/adeRuntimeProtocol"; import { PersonalChatScope } from "./services/personalChats/personalChatScope"; import { JsonRpcErrorCode } from "./jsonrpc"; @@ -1787,6 +1791,62 @@ describe("multi-project RPC server", () => { handler.dispose(); }); + it("reports the brain's real projectless sync state instead of the all-down placeholder", async () => { + // A brain hosting sync with no project holds the machine lease and has the + // shared listener bound. It injects that truth; the handler must report it + // rather than the hardcoded pessimistic snapshot that made `ade doctor` + // call a reachable machine unreachable. + const { registry } = createRegistry(); + const scopeRegistry = { + get: vi.fn(), + ensureSyncHost: vi.fn(), + switchSyncHost: vi.fn(), + resolveActiveSyncHost: vi.fn(async () => null), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const projectless = buildProjectlessSyncSnapshot({ + secretsDir: path.join(os.tmpdir(), "ade-projectless-rpc-missing"), + listener: { + getPort: () => 8791, + getLoopbackValidationStatus: () => ({ + port: 8791, + loopbackAdeValidated: true, + lastFailureAt: null, + reason: null, + lastSuccessAt: "2026-07-16T00:00:00.000Z", + }), + }, + holdsSyncHostLease: true, + relay: { accountSignedIn: true, wssUrl: null, status: null }, + accountDirectory: createSyncAccountDirectoryHealth("published", null), + }); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + getProjectlessSyncSnapshot: () => projectless, + }); + + await handler({ jsonrpc: "2.0", id: 1, method: "ade/initialize", params: {} }); + const status = await handler({ + jsonrpc: "2.0", + id: 2, + method: "sync.getStatus", + params: {}, + }) as SyncRoleSnapshot; + + expect(status.routeHealth.listener).toMatchObject({ + listenerBound: true, + loopbackAdeValidated: true, + port: 8791, + }); + expect(status.pairingConnectInfo?.port).toBe(8791); + expect(status.routeHealth.relay.enabled).toBe(true); + expect(status.runtimeRole).toBe("host"); + handler.dispose(); + }); + it("rejects desktop/TUI sync host switches through the runtime RPC", async () => { const { root, projectRoot, registry } = createRegistry(); const first = registry.add(projectRoot); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 402510293..30badd784 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -80,7 +80,7 @@ import { AccountMachineDirectoryService, reconcileAccountOwnedMachineTrust, } from "./services/account/accountMachineDirectoryService"; -import { mapPlatform } from "./services/sync/syncProtocol"; +import { buildDegradedProjectlessSyncSnapshot } from "./services/sync/projectlessSyncSnapshot"; import { RUNTIME_COMPAT_LEVEL } from "../../desktop/src/shared/adeRuntimeProtocol"; type HandlerEntry = { @@ -140,6 +140,8 @@ export type MultiProjectRpcHandlerOptions = { accountAuthService?: AccountAuthService; productAnalyticsService?: AccountAnalyticsIdentity; getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth; + /** Truthful sync status for a brain hosting (or not hosting) sync with no project scope. */ + getProjectlessSyncSnapshot?: () => SyncRoleSnapshot; getRuntimeStatus?: () => { syncPort: number | null; publishHealth: Pick< @@ -1077,112 +1079,17 @@ export function createMultiProjectRpcRequestHandler( } }; - const readMachineSyncIdentity = (fileName: string): string => { - try { - return fs.readFileSync( - path.join(resolveMachineAdeLayout().secretsDir, fileName), - "utf8", - ).trim(); - } catch { - return ""; - } - }; - - const getMachineOnlySyncStatus = (): SyncRoleSnapshot => { - const now = new Date().toISOString(); - const localDevice: SyncRoleSnapshot["localDevice"] = { - deviceId: readMachineSyncIdentity("sync-device-id"), - siteId: readMachineSyncIdentity("sync-site-id"), - name: os.hostname(), - platform: mapPlatform(process.platform), - deviceType: "desktop", - createdAt: now, - updatedAt: now, - lastSeenAt: now, - lastHost: os.hostname(), - lastPort: null, - tailscaleIp: null, - ipAddresses: [], - metadata: { hostname: os.hostname() }, - }; - return { - mode: "standalone", - role: "brain", - runtimeMode: "standalone", - runtimeRole: "host", - localDevice, - currentBrain: localDevice, - currentRuntime: localDevice, - clusterState: null, - bootstrapToken: null, - pairingPin: null, - pairingPinConfigured: false, - runtimeName: null, - pairingConnectInfo: null, - connectedPeers: [], - tailnetDiscovery: { - state: "disabled", - serviceName: "svc:ade-sync", - servicePort: 8787, - target: null, - updatedAt: null, - error: "Tailnet discovery is waiting for an active sync project scope.", - stderr: null, - }, - routeHealth: { - listener: { - listenerBound: false, - loopbackAdeValidated: false, - port: null, - lastFailureAt: null, - reason: "No active sync project scope.", - lastSuccessAt: null, - }, - tailscale: { - enabled: false, - tailscalePublished: false, - tailscaleReachable: false, - lastFailureAt: null, - reason: null, - lastSuccessAt: null, - }, - relay: { - enabled: false, - relayControlConnected: false, - relayBridgeValidated: false, - lastFailureAt: null, - skipReason: null, - lastControlError: null, - lastControlOpenAt: null, - lastBridgeValidationAt: null, - }, - accountDirectory: getAccountDirectoryHealth(), - }, - client: { - state: "disconnected", - host: null, - port: null, - connectedAt: null, - lastSeenAt: null, - latencyMs: null, - syncLag: null, - lastRemoteDbVersion: 0, - brainDeviceId: null, - hostDeviceId: null, - hostName: null, - error: null, - message: "No active sync project scope.", - savedDraft: null, - }, - transferReadiness: { - ready: false, - blockers: [], - survivableState: [], - }, - survivableStateText: "No active sync project scope.", - blockingStateText: "Register or open a project to start machine sync.", - }; - }; + // A brain with no project scope can still be hosting phone sync — it holds + // the machine-wide lease and the shared listener is bound on a real port. The + // brain that owns those facts injects `getProjectlessSyncSnapshot`; a handler + // built without it (tests, embedded runtimes) has no listener to describe and + // falls back to the honest all-down shape. + const getMachineOnlySyncStatus = (): SyncRoleSnapshot => + options.getProjectlessSyncSnapshot?.() + ?? buildDegradedProjectlessSyncSnapshot({ + secretsDir: resolveMachineAdeLayout().secretsDir, + accountDirectory: getAccountDirectoryHealth(), + }); const trimmedEnvOrNull = (key: string): string | null => { const value = process.env[key]; diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 2a6c102c2..acdbd9a89 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ACCOUNT_MACHINE_HEARTBEAT_MS, @@ -12,7 +15,10 @@ import { DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, } from "../../../../desktop/src/shared/accountDirectory"; +import { buildProjectlessSyncSnapshot } from "../sync/projectlessSyncSnapshot"; +import { createSyncAccountDirectoryHealth } from "../../../../desktop/src/shared/types"; import type { ProductAnalyticsCapture } from "../../../../desktop/src/shared/types/productAnalytics"; +import { removeTestTree } from "../../test/filesystem"; import { createEpisodeAnalytics, EPISODE_ANALYTICS_MINIMUM_INTERVAL_MS, @@ -132,10 +138,15 @@ function routeSnapshot( return value; } -afterEach(() => { +const projectlessSecretsDirs: string[] = []; + +afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); + for (const dir of projectlessSecretsDirs.splice(0)) { + await removeTestTree(dir); + } }); describe("account machine publisher health", () => { @@ -1062,6 +1073,59 @@ describe("account machine publisher health", () => { service.dispose(); }); + it("publishes a machine that hosts sync with no project registered", async () => { + // The bug this pins: a signed-in machine with an empty projects.json never + // reached the network at all. The publisher bailed at `no_active_sync_scope` + // because the only snapshot source was the active project scope, even though + // the brain was hosting phone sync on a real, bound, loopback-validated port. + const secretsDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-projectless-publish-")); + projectlessSecretsDirs.push(secretsDir); + fs.writeFileSync(path.join(secretsDir, "sync-device-id"), "device-headless\n"); + fs.writeFileSync(path.join(secretsDir, "sync-site-id"), "site-headless\n"); + const projectless = buildProjectlessSyncSnapshot({ + secretsDir, + listener: { + getPort: () => 8791, + getLoopbackValidationStatus: () => ({ + port: 8791, + loopbackAdeValidated: true, + lastFailureAt: null, + reason: null, + lastSuccessAt: "2026-07-16T00:00:00.000Z", + }), + }, + holdsSyncHostLease: true, + relay: { accountSignedIn: true, wssUrl: null, status: null }, + accountDirectory: createSyncAccountDirectoryHealth("sync_disabled", null), + }); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ signedIn: true, sessionReadState: "available" as const }), + getSnapshot: async () => projectless, + getMachineKey: () => "machine-headless", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + + expect(service.getPublisherHealth().state).toBe("published"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchImpl.mock.calls[0]![1]!.body)) as { + machineKey: string; + deviceId: string; + name: string; + }; + expect(body.machineKey).toBe("machine-headless"); + expect(body.deviceId).toBe("device-headless"); + expect(body.name.trim().length).toBeGreaterThan(0); + service.dispose(); + }); + it("publishes when the reachable endpoint set changes", async () => { vi.useFakeTimers(); const current = snapshot(); diff --git a/apps/ade-cli/src/services/sync/deviceRegistryService.ts b/apps/ade-cli/src/services/sync/deviceRegistryService.ts index e109fd87b..f4fdc139c 100644 --- a/apps/ade-cli/src/services/sync/deviceRegistryService.ts +++ b/apps/ade-cli/src/services/sync/deviceRegistryService.ts @@ -91,6 +91,9 @@ function execFileText( encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024, + // The Tailscale probe re-runs on the 30 s cache TTL for the life of the + // brain, so without this every refresh flashes a console window. + windowsHide: true, }, (error, stdout) => { resolve(error ? null : String(stdout ?? "")); }); @@ -232,6 +235,42 @@ function firstPreferredHost(ipAddresses: string[]): string { return ipAddresses[0] ?? os.hostname(); } +export type LocalSyncDeviceDefaults = { + name: string; + platform: SyncPeerPlatform; + deviceType: SyncPeerDeviceType; + ipAddresses: string[]; + tailscaleIp: string | null; + lastHost: string; + metadata: Record; +}; + +/** + * Everything this machine knows about itself without consulting a database: + * display name, platform, and the addresses a phone could dial. Module-scoped + * (not a closure over the registry) because a brain hosting sync WITHOUT a + * project scope has no project DB to read yet still has to describe the same + * machine — see `buildProjectlessSyncSnapshot`. + */ +export function localSyncDeviceDefaults(): LocalSyncDeviceDefaults { + const network = readLocalNetworkMetadata(); + const metadata: Record = { + hostname: os.hostname(), + }; + if (network.tailscaleDnsName) { + metadata.tailscaleDnsName = network.tailscaleDnsName; + } + return { + name: resolveDeviceDisplayName(), + platform: mapPlatform(process.platform), + deviceType: "desktop" as SyncPeerDeviceType, + ipAddresses: network.lanIpAddresses, + tailscaleIp: network.tailscaleIp, + lastHost: firstPreferredHost(network.lanIpAddresses), + metadata, + }; +} + export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); @@ -265,25 +304,6 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const localDeviceId = readOrCreateLocalDeviceId(); const localSiteId = args.db.sync.getSiteId(); - const getLocalDefaults = () => { - const network = readLocalNetworkMetadata(); - const metadata: Record = { - hostname: os.hostname(), - }; - if (network.tailscaleDnsName) { - metadata.tailscaleDnsName = network.tailscaleDnsName; - } - return { - name: resolveDeviceDisplayName(), - platform: mapPlatform(process.platform), - deviceType: "desktop" as SyncPeerDeviceType, - ipAddresses: network.lanIpAddresses, - tailscaleIp: network.tailscaleIp, - lastHost: firstPreferredHost(network.lanIpAddresses), - metadata, - }; - }; - const upsertDeviceRecord = (record: { deviceId: string; siteId: string; @@ -349,7 +369,7 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const ensureLocalDevice = (): SyncDeviceRecord => { const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId])); - const defaults = getLocalDefaults(); + const defaults = localSyncDeviceDefaults(); return upsertDeviceRecord({ deviceId: localDeviceId, siteId: localSiteId, diff --git a/apps/ade-cli/src/services/sync/machineRelayTunnel.ts b/apps/ade-cli/src/services/sync/machineRelayTunnel.ts new file mode 100644 index 000000000..91c6af02b --- /dev/null +++ b/apps/ade-cli/src/services/sync/machineRelayTunnel.ts @@ -0,0 +1,101 @@ +import type { AccountAuthService } from "../account/accountAuthService"; +import type { RelayTunnelAuthorityGate } from "./relayTunnelAuthorityGate"; +import type { SyncTunnelClientService, TunnelHostListener } from "./syncTunnelClientService"; + +/** + * The machine's ONE relay tunnel client, plus the lease gate that decides + * whether this process may run it. + * + * Both brains that can host phone sync build this: `createAdeRuntime`, for a + * project scope, and `runServe`'s projectless path, for a machine with nothing + * registered. They must produce the same thing — the relay Durable Object keeps + * one host control socket per machineKey and evicts the previous holder with + * close code 4505, so two clients on one machine evict each other in a loop and + * relay stays down for both. + * + * Only the reaction to a publication-state change genuinely differs between the + * two, so that stays a parameter. + */ + +type SyncTunnelClientArgs = Parameters< + typeof import("./syncTunnelClientService")["createSyncTunnelClientService"] +>[0]; + +export type MachineRelayTunnelArgs = { + logger?: SyncTunnelClientArgs["logger"]; + configStore: SyncTunnelClientArgs["configStore"]; + /** + * The relay config file. It doubles as the machine-wide cache key, so a + * project scope that boots after the projectless brain adopts that brain's + * client instead of registering the same machineKey a second time. + */ + configPath: string; + /** Relay is usable only while the host has a current ADE account session. */ + accountAuthService: Pick; + /** + * The shared sync listener the relay bridges into, or null for a runtime that + * does not host phone sync at all. + */ + hostListener: TunnelHostListener | null; + onPublicationStateChanged: NonNullable; + captureAnalytics: NonNullable; +}; + +export async function createMachineRelayTunnel(args: MachineRelayTunnelArgs): Promise<{ + tunnel: SyncTunnelClientService; + gate: RelayTunnelAuthorityGate; +}> { + const [ + { createSyncTunnelClientService, getSharedSyncTunnelClientService }, + { createRelayTunnelAuthorityGate }, + { holdsSyncHostSingleton, onSyncHostSingletonAuthorityChanged }, + ] = await Promise.all([ + import("./syncTunnelClientService"), + import("./relayTunnelAuthorityGate"), + import("./syncHostSingleton"), + ]); + const tunnel = getSharedSyncTunnelClientService(args.configPath, () => + createSyncTunnelClientService({ + logger: args.logger, + configStore: args.configStore, + isAccountSignedIn: () => { + const status = args.accountAuthService.getStatus(); + return status.signedIn && Boolean(status.userId?.trim()); + }, + getAccountLease: async () => { + const status = args.accountAuthService.getStatus(); + const userId = status.signedIn ? status.userId?.trim() || null : null; + if (!userId) return null; + const token = (await args.accountAuthService.getAccessToken()).trim(); + const refreshed = args.accountAuthService.getStatus(); + return token && refreshed.signedIn && refreshed.userId?.trim() === userId + ? { userId, expiresAt: refreshed.expiresAt } + : null; + }, + onPublicationStateChanged: args.onPublicationStateChanged, + // The analytics sink is machine-scoped and shared, so capturing it in this + // one-per-machine factory closure is safe — unlike listener accessors, + // which is why those go through attachHostListener below. + captureAnalytics: args.captureAnalytics, + }), + ); + // Bind the listener OUTSIDE the factory, and before the gate. The client is + // cached one-per-machine and built by whichever runtime bootstrapped first, + // which is regularly a scope with no listener (headless one-shot, embedded + // fallback); attaching here means the runtime that actually owns the listener + // wins regardless of who created the instance. Attaching before the gate also + // means the gate's very first `start()` already has a bridge to validate + // against, since no later event would re-trigger validation. + if (args.hostListener) tunnel.attachHostListener(args.hostListener); + // Only the runtime holding the machine-wide sync host lease may register the + // tunnel. See relayTunnelAuthorityGate for why the old "has a listener" gate + // let secondary brains evict the real host. + const gate = createRelayTunnelAuthorityGate({ + hostListener: args.hostListener, + tunnel, + holdsLease: holdsSyncHostSingleton, + subscribe: onSyncHostSingletonAuthorityChanged, + logger: args.logger, + }); + return { tunnel, gate }; +} diff --git a/apps/ade-cli/src/services/sync/projectlessSyncSnapshot.ts b/apps/ade-cli/src/services/sync/projectlessSyncSnapshot.ts new file mode 100644 index 000000000..cf485fb8e --- /dev/null +++ b/apps/ade-cli/src/services/sync/projectlessSyncSnapshot.ts @@ -0,0 +1,231 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + createSyncAccountDirectoryHealth, + type SyncAccountDirectoryHealth, + type SyncDeviceRecord, + type SyncRoleSnapshot, +} from "../../../../desktop/src/shared/types"; +import { localSyncDeviceDefaults } from "./deviceRegistryService"; +import { buildPairingConnectInfo } from "./syncPairingConnectInfo"; +import { buildRelayRouteHealth, deriveListenerHealth } from "./syncRouteHealth"; +import type { SyncLoopbackValidationStatus } from "./syncLoopbackProbe"; +import type { SyncTunnelClientStatus } from "./syncTunnelClientService"; +import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; + +/** + * The sync snapshot for a brain that has NO project scope. + * + * A machine with nothing in `~/.ade/projects.json` still hosts phone sync: the + * brain takes the machine-wide sync-host lease and binds the shared listener on + * a real port (see the projectless branch of `startSyncHost` in cli.ts). Every + * consumer of sync status, though, used to be fed a hardcoded all-down + * placeholder, because the only snapshot source was the active project scope's + * `syncService.getStatus()` and there was no active scope. + * + * That placeholder was not merely incomplete, it was wrong: it reported + * `listenerBound: false` and `pairingConnectInfo: null` for a listener that was + * genuinely bound. `ade doctor` misreported the machine as unreachable, and — + * the expensive part — the account-directory publisher gates on exactly those + * two fields, so a signed-in projectless machine could never publish itself and + * never appeared in the user's ADE account. + * + * This builder reports the truth for both states. When the brain is serving + * projectless it describes the real listener, the real machine identity, and + * the real relay; when it is not, it keeps the honest all-down shape rather + * than optimistically claiming a host that does not exist. + */ + +export type ProjectlessSyncSnapshotArgs = { + /** Machine-level `~/.ade/secrets`, where the brain persists its sync identity. */ + secretsDir: string; + /** + * The brain's shared sync listener, or null in a process that never built one + * (sync disabled, or a consumer that only wants the degraded shape). + */ + listener: { + getPort(): number | null; + getLoopbackValidationStatus(): SyncLoopbackValidationStatus; + } | null; + /** + * Whether this process holds the machine-wide sync-host lease. Hosting is the + * lease AND a bound listener: a brain that bound a port without winning the + * lease is not the machine's sync host and must not advertise itself as one. + */ + holdsSyncHostLease: boolean; + relay: { + /** Signed in to an ADE account with a usable session. */ + accountSignedIn: boolean; + /** This machine's relay URL, already gated on the tunnel being usable. */ + wssUrl: string | null; + status: SyncTunnelClientStatus | null; + }; + accountDirectory: SyncAccountDirectoryHealth; +}; + +const NO_SCOPE_REASON = "No active sync project scope."; + +/** Device identity the brain persists per machine; readable without a project DB. */ +function readMachineSyncIdentity(secretsDir: string, fileName: string): string { + try { + return fs.readFileSync(path.join(secretsDir, fileName), "utf8").trim(); + } catch { + return ""; + } +} + +export function buildProjectlessSyncSnapshot( + args: ProjectlessSyncSnapshotArgs, +): SyncRoleSnapshot { + const now = new Date().toISOString(); + const listenerPort = args.listener?.getPort() ?? null; + // Binding the shared listener IS hosting phone sync, but only the lease + // holder may say so — see the same gate in relayTunnelAuthorityGate. + const hosting = args.holdsSyncHostLease && listenerPort != null; + + const defaults = localSyncDeviceDefaults(); + const localDevice: SyncDeviceRecord = { + deviceId: readMachineSyncIdentity(args.secretsDir, "sync-device-id"), + siteId: readMachineSyncIdentity(args.secretsDir, "sync-site-id"), + name: defaults.name, + platform: defaults.platform, + deviceType: defaults.deviceType, + createdAt: now, + updatedAt: now, + lastSeenAt: now, + lastHost: defaults.lastHost, + lastPort: listenerPort, + tailscaleIp: defaults.tailscaleIp, + ipAddresses: defaults.ipAddresses, + metadata: defaults.metadata, + }; + + const { loopbackAdeValidated, listenerReason, listener: listenerRouteHealth } = + deriveListenerHealth({ + listenerPort, + bound: hosting, + notBoundReason: NO_SCOPE_REASON, + rawValidation: args.listener?.getLoopbackValidationStatus() ?? { + port: null, + loopbackAdeValidated: false, + lastFailureAt: null, + reason: NO_SCOPE_REASON, + lastSuccessAt: null, + }, + }); + + // Same contract as the scoped path: relay is configured whenever this runtime + // can host phone pairing and enabled once the account is signed in. There is + // no user toggle. + const relayRouteHealth = buildRelayRouteHealth({ + relayConfigured: hosting, + relayAccountSignedIn: args.relay.accountSignedIn + && (args.relay.status?.accountLeaseValid ?? true), + loopbackAdeValidated, + listenerReason, + listenerPort, + tunnelStatus: args.relay.status, + // No listener-probe history to borrow from: this builder is constructed + // fresh on every call and keeps no state across listener restarts. + lastFailureAtFallback: null, + }); + + const blockingStateText = hosting + ? "Register or open a project to sync its chats, lanes, and terminals." + : "Register or open a project to start machine sync."; + const statusText = hosting + ? "This machine hosts phone sync without an open project." + : NO_SCOPE_REASON; + + return { + mode: "standalone", + role: "brain", + runtimeMode: "standalone", + runtimeRole: "host", + localDevice, + currentBrain: localDevice, + currentRuntime: localDevice, + clusterState: null, + bootstrapToken: null, + // Publishing a machine to the account directory deliberately does NOT + // require a pairing code: account membership is the auth path, and the PIN + // is only a fallback for nearby devices that are not signed in. + pairingPin: null, + pairingPinConfigured: false, + // The runtime name is a per-project-scope setting; a projectless brain has + // no scope to read one from. + runtimeName: null, + pairingConnectInfo: hosting + ? buildPairingConnectInfo({ + localDevice, + relayWssUrl: args.relay.wssUrl, + }) + : null, + connectedPeers: [], + tailnetDiscovery: { + state: "disabled", + serviceName: "svc:ade-sync", + servicePort: DEFAULT_SYNC_HOST_PORT, + target: null, + updatedAt: null, + // Tailscale Serve is published by a project scope's sync host service, + // which a projectless brain does not run. + error: "Tailnet discovery is waiting for an active sync project scope.", + stderr: null, + }, + routeHealth: { + listener: listenerRouteHealth, + tailscale: { + enabled: false, + tailscalePublished: false, + tailscaleReachable: false, + lastFailureAt: null, + reason: null, + lastSuccessAt: null, + }, + relay: relayRouteHealth, + accountDirectory: args.accountDirectory, + }, + client: { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: null, + hostDeviceId: null, + hostName: null, + error: null, + message: statusText, + savedDraft: null, + }, + transferReadiness: { + ready: false, + blockers: [], + survivableState: [], + }, + survivableStateText: statusText, + blockingStateText, + }; +} + +/** The all-down shape, for callers with no listener and no lease to report. */ +export function buildDegradedProjectlessSyncSnapshot(args: { + secretsDir: string; + accountDirectory?: SyncAccountDirectoryHealth; +}): SyncRoleSnapshot { + return buildProjectlessSyncSnapshot({ + secretsDir: args.secretsDir, + listener: null, + holdsSyncHostLease: false, + relay: { accountSignedIn: false, wssUrl: null, status: null }, + accountDirectory: args.accountDirectory + ?? createSyncAccountDirectoryHealth( + "sync_disabled", + "Account-directory publishing is not enabled in this brain.", + ), + }); +} diff --git a/apps/ade-cli/src/services/sync/syncRouteHealth.ts b/apps/ade-cli/src/services/sync/syncRouteHealth.ts new file mode 100644 index 000000000..38c7760c2 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRouteHealth.ts @@ -0,0 +1,171 @@ +import type { SyncRouteHealth } from "../../../../desktop/src/shared/types"; +import type { SyncLoopbackValidationStatus } from "./syncLoopbackProbe"; +import type { SyncTunnelClientStatus } from "./syncTunnelClientService"; + +/** + * How a machine describes its own inbound routes. + * + * Two callers derive this from the same raw inputs: `syncService.getStatus`, + * for a brain with an active project scope, and `buildProjectlessSyncSnapshot`, + * for one hosting phone sync with no scope at all. The strings are what the + * user actually reads in Settings and in `ade doctor`, and the account-machine + * publisher gates on the booleans — so the two paths disagreeing is not a + * cosmetic drift, it is one of them lying about whether the machine is + * reachable. + * + * The genuine differences between the two are parameters, not branches inside + * these functions: what "not bound" means (a listener that failed to start vs + * a machine with no scope), and which timestamp stands in when the tunnel + * itself reports no failure time. + */ + +export type ListenerRouteHealth = SyncRouteHealth["listener"]; + +/** + * The relay shape with the end-to-end probe fields kept. They belong to + * `AccountMachineRegistrationSnapshot`'s widened relay shape, which + * `SyncRouteHealth` itself does not declare, and a bare object literal + * assignment would drop them. + */ +export type RelayRouteHealth = SyncRouteHealth["relay"] & { + relayEndToEndVerifiedAt: string | null; + relayEndToEndFailure: string | null; + relayEndToEndRoundTripMs: number | null; +}; + +export type ListenerHealth = { + loopbackAdeValidated: boolean; + /** Also feeds the Tailscale and Relay reasons, which quote it verbatim. */ + listenerReason: string | null; + listener: ListenerRouteHealth; +}; + +export type DeriveListenerHealthArgs = { + listenerPort: number | null; + rawValidation: SyncLoopbackValidationStatus; + /** + * Whether this process is actually serving on that port. A bound port is not + * enough for the projectless brain, which must also hold the machine-wide + * sync-host lease. + */ + bound: boolean; + /** What to tell the user when `bound` is false; the two callers mean different things by it. */ + notBoundReason: string; + /** + * Last-probe timestamps the caller has accumulated across listener restarts, + * used when the current validation result carries none. + */ + validationHistory?: Pick; +}; + +export type BuildRelayRouteHealthArgs = { + /** This runtime can host phone pairing at all. There is no user toggle. */ + relayConfigured: boolean; + relayAccountSignedIn: boolean; + loopbackAdeValidated: boolean; + listenerReason: string | null; + listenerPort: number | null; + tunnelStatus: SyncTunnelClientStatus | null; + /** + * Stands in for `lastFailureAt` while relay is enabled but skipped and the + * tunnel has no failure time of its own — normally the loopback probe's last + * failure. Pass null to report no time rather than borrow one. + */ + lastFailureAtFallback: string | null; +}; + +function resolveListenerReason(args: { + bound: boolean; + notBoundReason: string; + loopbackAdeValidated: boolean; + rawValidation: SyncLoopbackValidationStatus; + listenerPort: number | null; +}): string | null { + if (!args.bound) return args.notBoundReason; + if (args.loopbackAdeValidated) return null; + return args.rawValidation.reason ?? `127.0.0.1:${args.listenerPort} did not answer as ADE.`; +} + +export function deriveListenerHealth(args: DeriveListenerHealthArgs): ListenerHealth { + // A validation result only counts while it still describes the port that is + // currently bound; a stale one would report a rebound listener as healthy. + const loopbackAdeValidated = args.bound + && args.rawValidation.port === args.listenerPort + && args.rawValidation.loopbackAdeValidated; + const listenerReason = resolveListenerReason({ + bound: args.bound, + notBoundReason: args.notBoundReason, + loopbackAdeValidated, + rawValidation: args.rawValidation, + listenerPort: args.listenerPort, + }); + return { + loopbackAdeValidated, + listenerReason, + listener: { + listenerBound: args.bound, + loopbackAdeValidated, + port: args.bound ? args.listenerPort : null, + lastFailureAt: args.rawValidation.lastFailureAt ?? args.validationHistory?.lastFailureAt ?? null, + reason: listenerReason, + lastSuccessAt: args.rawValidation.lastSuccessAt ?? args.validationHistory?.lastSuccessAt ?? null, + }, + }; +} + +function resolveRelaySkipReason(args: BuildRelayRouteHealthArgs & { + relayEnabled: boolean; + relayControlConnected: boolean; + relayBridgeValidated: boolean; +}): string | null { + if (args.relayConfigured && !args.relayAccountSignedIn) return "Sign in to ADE to use ADE Relay."; + if (!args.relayEnabled) return null; + if (!args.loopbackAdeValidated) { + return `Relay route is unusable because ${args.listenerReason ?? "the loopback ADE check failed"}`; + } + const status = args.tunnelStatus; + if (!status) return "Relay tunnel status is unavailable in this ADE process."; + if (!args.relayControlConnected) { + // A 4505 eviction is a machine-local ownership conflict, not a network + // fault, and its reason is the only one that tells the user what to + // actually do — so it outranks the raw close text. + return status.controlSuppressedReason + ?? status.lastControlError + ?? status.lastError + ?? "Relay control is not connected."; + } + if (!args.relayBridgeValidated) { + return status.lastError + ?? `Relay bridge to 127.0.0.1:${args.listenerPort} has not been validated against the current sync port.`; + } + return status.bridgeOpenFailure ?? null; +} + +export function buildRelayRouteHealth(args: BuildRelayRouteHealthArgs): RelayRouteHealth { + const relayEnabled = args.relayConfigured && args.relayAccountSignedIn; + const relayControlConnected = args.tunnelStatus?.connected === true; + const relayBridgeValidated = args.tunnelStatus?.relayBridgeValidated === true; + const skipReason = resolveRelaySkipReason({ + ...args, + relayEnabled, + relayControlConnected, + relayBridgeValidated, + }); + return { + enabled: relayEnabled, + relayControlConnected, + relayBridgeValidated, + lastFailureAt: args.tunnelStatus?.lastFailureAt + ?? (relayEnabled && skipReason ? args.lastFailureAtFallback : null), + skipReason, + lastControlError: args.tunnelStatus?.lastControlError ?? null, + lastControlOpenAt: args.tunnelStatus?.lastControlOpenAt ?? null, + lastBridgeValidationAt: args.tunnelStatus?.lastBridgeValidationAt ?? null, + relayEndToEndVerifiedAt: args.tunnelStatus?.relayEndToEndVerifiedAt ?? null, + relayEndToEndFailure: args.tunnelStatus?.relayEndToEndFailure ?? null, + relayEndToEndRoundTripMs: args.tunnelStatus?.relayEndToEndRoundTripMs ?? null, + relayControlSuppressed: args.tunnelStatus?.controlSuppressed === true, + relayControlSuppressedReason: args.tunnelStatus?.controlSuppressedReason ?? null, + relayControlFailingSinceMs: args.tunnelStatus?.controlFailingSinceMs ?? null, + }; +} diff --git a/apps/ade-cli/src/services/sync/syncService.test.ts b/apps/ade-cli/src/services/sync/syncService.test.ts index d094cfded..7a9ba106b 100644 --- a/apps/ade-cli/src/services/sync/syncService.test.ts +++ b/apps/ade-cli/src/services/sync/syncService.test.ts @@ -5,6 +5,15 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb, type AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; import { createSyncService, type SyncService } from "./syncService"; +import { + buildDegradedProjectlessSyncSnapshot, + buildProjectlessSyncSnapshot, + type ProjectlessSyncSnapshotArgs, +} from "./projectlessSyncSnapshot"; +import { buildRelayRouteHealth, deriveListenerHealth } from "./syncRouteHealth"; +import type { SyncLoopbackValidationStatus } from "./syncLoopbackProbe"; +import type { SyncTunnelClientStatus } from "./syncTunnelClientService"; +import { createSyncAccountDirectoryHealth } from "../../../../desktop/src/shared/types"; import { removeTestTree } from "../../test/filesystem"; vi.mock("../../../../desktop/src/main/services/state/crsqliteExtension", async (importOriginal) => { @@ -322,3 +331,282 @@ describe("createSyncService", () => { } }); }); + +describe("buildProjectlessSyncSnapshot", () => { + const cleanupRoots: string[] = []; + + afterEach(async () => { + for (const root of cleanupRoots.splice(0)) { + await removeTestTree(root); + } + }); + + function makeSecretsDir(): string { + const root = makeTempRoot("ade-projectless-snapshot-"); + cleanupRoots.push(root); + const secretsDir = path.join(root, "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync(path.join(secretsDir, "sync-device-id"), "device-machine\n"); + fs.writeFileSync(path.join(secretsDir, "sync-site-id"), "site-machine\n"); + return secretsDir; + } + + function args( + secretsDir: string, + overrides: Partial = {}, + ): ProjectlessSyncSnapshotArgs { + return { + secretsDir, + listener: { + getPort: () => 8791, + getLoopbackValidationStatus: () => ({ + port: 8791, + loopbackAdeValidated: true, + lastFailureAt: null, + reason: null, + lastSuccessAt: "2026-07-16T00:00:00.000Z", + }), + }, + holdsSyncHostLease: true, + relay: { accountSignedIn: false, wssUrl: null, status: null }, + accountDirectory: createSyncAccountDirectoryHealth("published", null), + ...overrides, + }; + } + + it("reports the bound listener and real pairing connect info while hosting without a project", () => { + const secretsDir = makeSecretsDir(); + + const snapshot = buildProjectlessSyncSnapshot(args(secretsDir)); + + expect(snapshot.routeHealth.listener).toMatchObject({ + listenerBound: true, + loopbackAdeValidated: true, + port: 8791, + reason: null, + }); + expect(snapshot.runtimeRole).toBe("host"); + expect(snapshot.pairingConnectInfo).not.toBeNull(); + expect(snapshot.pairingConnectInfo?.port).toBe(8791); + expect(snapshot.pairingConnectInfo?.hostIdentity.deviceId).toBe("device-machine"); + expect(snapshot.pairingConnectInfo?.hostIdentity.siteId).toBe("site-machine"); + // A published machine needs no pairing code: account membership is the auth + // path and the PIN is only a fallback for nearby unsigned-in devices. + expect(snapshot.pairingPinConfigured).toBe(false); + expect(snapshot.localDevice.lastPort).toBe(8791); + }); + + it("stays pessimistic when the lease is not held or the listener is unbound", () => { + const secretsDir = makeSecretsDir(); + + const noLease = buildProjectlessSyncSnapshot(args(secretsDir, { holdsSyncHostLease: false })); + const noListener = buildProjectlessSyncSnapshot(args(secretsDir, { listener: null })); + const degraded = buildDegradedProjectlessSyncSnapshot({ secretsDir }); + + for (const snapshot of [noLease, noListener, degraded]) { + expect(snapshot.routeHealth.listener.listenerBound).toBe(false); + expect(snapshot.routeHealth.listener.port).toBeNull(); + expect(snapshot.pairingConnectInfo).toBeNull(); + expect(snapshot.routeHealth.relay.enabled).toBe(false); + expect(snapshot.routeHealth.listener.reason).toBe("No active sync project scope."); + } + }); + + it("enables relay purely from hosting plus a signed-in account, with no project", () => { + const secretsDir = makeSecretsDir(); + + const signedOut = buildProjectlessSyncSnapshot(args(secretsDir)); + const signedIn = buildProjectlessSyncSnapshot(args(secretsDir, { + relay: { + accountSignedIn: true, + wssUrl: "wss://relay.example/connect/machine-key", + status: { + accountLeaseValid: true, + connected: true, + relayBridgeValidated: true, + activeTunnels: 0, + lastError: null, + bridgeOpenFailure: null, + lastControlError: null, + validatedPort: 8791, + lastFailureAt: null, + lastControlOpenAt: "2026-07-16T00:00:00.000Z", + lastBridgeValidationAt: "2026-07-16T00:00:00.000Z", + relayEndToEndVerifiedAt: "2026-07-16T00:00:01.000Z", + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: 42, + controlSuppressed: false, + controlSuppressedReason: null, + controlFailingSinceMs: null, + } as ProjectlessSyncSnapshotArgs["relay"]["status"], + }, + })); + + expect(signedOut.routeHealth.relay.enabled).toBe(false); + expect(signedOut.routeHealth.relay.skipReason).toBe("Sign in to ADE to use ADE Relay."); + expect(signedIn.routeHealth.relay).toMatchObject({ + enabled: true, + relayControlConnected: true, + relayBridgeValidated: true, + skipReason: null, + }); + expect(signedIn.pairingConnectInfo?.addressCandidates).toContainEqual({ + kind: "relay", + host: "wss://relay.example/connect/machine-key", + }); + }); +}); + +describe("syncRouteHealth", () => { + const validated: SyncLoopbackValidationStatus = { + port: 8791, + loopbackAdeValidated: true, + lastFailureAt: null, + reason: null, + lastSuccessAt: "2026-07-16T00:00:00.000Z", + }; + + function tunnelStatus(overrides: Partial): SyncTunnelClientStatus { + return { + connected: true, + activeTunnels: 0, + lastError: null, + lastControlError: null, + relayBridgeValidated: true, + validatedPort: 8791, + lastFailureAt: null, + lastControlOpenAt: "2026-07-16T00:00:00.000Z", + lastBridgeValidationAt: "2026-07-16T00:00:00.000Z", + relayEndToEndVerifiedAt: null, + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: null, + relayUrl: "wss://relay.example", + machineKey: "machine-key", + ...overrides, + }; + } + + it("keeps each caller's wording for an unbound listener and hides the port", () => { + const scoped = deriveListenerHealth({ + listenerPort: null, + rawValidation: { ...validated, port: null, loopbackAdeValidated: false }, + bound: false, + notBoundReason: "The ADE sync listener is not bound.", + }); + // A projectless brain can hold a bound port without the machine-wide + // sync-host lease. It is not the host, so it must not advertise the port. + const projectless = deriveListenerHealth({ + listenerPort: 8791, + rawValidation: validated, + bound: false, + notBoundReason: "No active sync project scope.", + }); + + expect(scoped.listener.reason).toBe("The ADE sync listener is not bound."); + expect(projectless.listener.reason).toBe("No active sync project scope."); + expect(projectless.listener.port).toBeNull(); + expect(projectless.loopbackAdeValidated).toBe(false); + }); + + it("discards a validation result that describes a different port", () => { + const health = deriveListenerHealth({ + listenerPort: 8792, + rawValidation: validated, + bound: true, + notBoundReason: "The ADE sync listener is not bound.", + }); + + expect(health.loopbackAdeValidated).toBe(false); + expect(health.listener.reason).toBe("127.0.0.1:8792 did not answer as ADE."); + }); + + it("fills probe timestamps from accumulated history only where the current result has none", () => { + const health = deriveListenerHealth({ + listenerPort: 8791, + rawValidation: validated, + bound: true, + notBoundReason: "The ADE sync listener is not bound.", + validationHistory: { + lastFailureAt: "2026-07-15T00:00:00.000Z", + lastSuccessAt: "2026-07-01T00:00:00.000Z", + }, + }); + + expect(health.listener.lastFailureAt).toBe("2026-07-15T00:00:00.000Z"); + expect(health.listener.lastSuccessAt).toBe("2026-07-16T00:00:00.000Z"); + }); + + it("borrows the loopback failure time only while relay is enabled and skipped", () => { + const base = { + relayConfigured: true, + loopbackAdeValidated: false, + listenerReason: "127.0.0.1:8791 did not answer as ADE.", + listenerPort: 8791, + tunnelStatus: null, + lastFailureAtFallback: "2026-07-15T00:00:00.000Z", + }; + + const skipped = buildRelayRouteHealth({ ...base, relayAccountSignedIn: true }); + const signedOut = buildRelayRouteHealth({ ...base, relayAccountSignedIn: false }); + const noFallback = buildRelayRouteHealth({ + ...base, + relayAccountSignedIn: true, + lastFailureAtFallback: null, + }); + + expect(skipped.skipReason) + .toBe("Relay route is unusable because 127.0.0.1:8791 did not answer as ADE."); + expect(skipped.lastFailureAt).toBe("2026-07-15T00:00:00.000Z"); + // Relay is not enabled at all, so there is no relay failure to date-stamp. + expect(signedOut.skipReason).toBe("Sign in to ADE to use ADE Relay."); + expect(signedOut.lastFailureAt).toBeNull(); + // A caller with no probe history reports no time rather than borrowing one. + expect(noFallback.lastFailureAt).toBeNull(); + }); + + it("ranks the 4505 eviction reason above the raw close text", () => { + const health = buildRelayRouteHealth({ + relayConfigured: true, + relayAccountSignedIn: true, + loopbackAdeValidated: true, + listenerReason: null, + listenerPort: 8791, + tunnelStatus: tunnelStatus({ + connected: false, + controlSuppressed: true, + controlSuppressedReason: "Another ADE process on this machine owns ADE Relay.", + lastControlError: "Relay control closed with code 4505.", + lastError: "socket hang up", + }), + lastFailureAtFallback: null, + }); + + expect(health.skipReason).toBe("Another ADE process on this machine owns ADE Relay."); + expect(health.relayControlSuppressed).toBe(true); + expect(health.enabled).toBe(true); + }); + + it("reports a bridge-open failure once control and bridge are both up", () => { + const healthy = buildRelayRouteHealth({ + relayConfigured: true, + relayAccountSignedIn: true, + loopbackAdeValidated: true, + listenerReason: null, + listenerPort: 8791, + tunnelStatus: tunnelStatus({}), + lastFailureAtFallback: null, + }); + const bridgeBlocked = buildRelayRouteHealth({ + relayConfigured: true, + relayAccountSignedIn: true, + loopbackAdeValidated: true, + listenerReason: null, + listenerPort: 8791, + tunnelStatus: tunnelStatus({ bridgeOpenFailure: "Local bridge socket refused." }), + lastFailureAtFallback: null, + }); + + expect(healthy.skipReason).toBeNull(); + expect(bridgeBlocked.skipReason).toBe("Local bridge socket refused."); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index d63d398ab..34718d2ae 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -75,6 +75,7 @@ import { buildPairingConnectInfo, tailscaleDnsNameFromDevice, } from "./syncPairingConnectInfo"; +import { buildRelayRouteHealth, deriveListenerHealth } from "./syncRouteHealth"; import type { PushPublisherService } from "../push/pushPublisherService"; import { acquireSyncHostSingleton, type SyncHostSingletonLease } from "./syncHostSingleton"; import type { SharedSyncListener } from "./sharedSyncListener"; @@ -1331,16 +1332,17 @@ export function createSyncService(args: SyncServiceArgs) { reason: "The ADE sync listener has not started.", lastSuccessAt: listenerValidationHistory.lastSuccessAt, }; - const listenerBound = listenerPort != null; - const loopbackAdeValidated = listenerBound - && rawListenerValidation.port === listenerPort - && rawListenerValidation.loopbackAdeValidated; - const listenerReason = !listenerBound - ? "The ADE sync listener is not bound." - : loopbackAdeValidated - ? null - : rawListenerValidation.reason - ?? `127.0.0.1:${listenerPort} did not answer as ADE.`; + const { + loopbackAdeValidated, + listenerReason, + listener: listenerRouteHealth, + } = deriveListenerHealth({ + listenerPort, + rawValidation: rawListenerValidation, + bound: listenerPort != null, + notBoundReason: "The ADE sync listener is not bound.", + validationHistory: listenerValidationHistory, + }); const tunnelStatus = args.syncTunnelClientService?.getStatus() ?? null; const tailscalePublished = tailnetDiscovery.state === "published"; const tailscaleEnabled = canHostPhonePairing @@ -1354,31 +1356,6 @@ export function createSyncService(args: SyncServiceArgs) { : tailscalePublished ? null : tailnetDiscovery.error ?? `Tailscale Serve is ${tailnetDiscovery.state}.`; - const relayConfigured = canHostPhonePairing; - const relayAccountSignedIn = isRelayAccountSignedIn() - && (tunnelStatus?.accountLeaseValid ?? true); - const relayEnabled = relayConfigured && relayAccountSignedIn; - const relayControlConnected = tunnelStatus?.connected === true; - const relayBridgeValidated = tunnelStatus?.relayBridgeValidated === true; - const relayReason = relayConfigured && !relayAccountSignedIn - ? "Sign in to ADE to use ADE Relay." - : !relayEnabled - ? null - : !loopbackAdeValidated - ? `Relay route is unusable because ${listenerReason ?? "the loopback ADE check failed"}` - : !tunnelStatus - ? "Relay tunnel status is unavailable in this ADE process." - : !relayControlConnected - // A 4505 eviction is a machine-local ownership conflict, not a - // network fault, and its reason is the only one that tells the - // user what to actually do — so it outranks the raw close text. - ? tunnelStatus.controlSuppressedReason - ?? tunnelStatus.lastControlError - ?? tunnelStatus.lastError - ?? "Relay control is not connected." - : !relayBridgeValidated - ? tunnelStatus.lastError ?? `Relay bridge to 127.0.0.1:${listenerPort} has not been validated against the current sync port.` - : tunnelStatus.bridgeOpenFailure ?? null; let accountDirectory: SyncAccountDirectoryHealth; try { accountDirectory = args.getAccountDirectoryHealth?.() ?? createSyncAccountDirectoryHealth( @@ -1391,32 +1368,18 @@ export function createSyncService(args: SyncServiceArgs) { "Account-directory publisher health is unavailable.", ); } - const relayRouteHealth = { - enabled: relayEnabled, - relayControlConnected, - relayBridgeValidated, - lastFailureAt: tunnelStatus?.lastFailureAt - ?? (relayEnabled && relayReason ? rawListenerValidation.lastFailureAt : null), - skipReason: relayReason, - lastControlError: tunnelStatus?.lastControlError ?? null, - lastControlOpenAt: tunnelStatus?.lastControlOpenAt ?? null, - lastBridgeValidationAt: tunnelStatus?.lastBridgeValidationAt ?? null, - relayEndToEndVerifiedAt: tunnelStatus?.relayEndToEndVerifiedAt ?? null, - relayEndToEndFailure: tunnelStatus?.relayEndToEndFailure ?? null, - relayEndToEndRoundTripMs: tunnelStatus?.relayEndToEndRoundTripMs ?? null, - relayControlSuppressed: tunnelStatus?.controlSuppressed === true, - relayControlSuppressedReason: tunnelStatus?.controlSuppressedReason ?? null, - relayControlFailingSinceMs: tunnelStatus?.controlFailingSinceMs ?? null, - }; + const relayRouteHealth = buildRelayRouteHealth({ + relayConfigured: canHostPhonePairing, + relayAccountSignedIn: isRelayAccountSignedIn() + && (tunnelStatus?.accountLeaseValid ?? true), + loopbackAdeValidated, + listenerReason, + listenerPort, + tunnelStatus, + lastFailureAtFallback: rawListenerValidation.lastFailureAt, + }); const routeHealth: SyncRouteHealth = { - listener: { - listenerBound, - loopbackAdeValidated, - port: listenerPort, - lastFailureAt: rawListenerValidation.lastFailureAt ?? listenerValidationHistory.lastFailureAt, - reason: listenerReason, - lastSuccessAt: rawListenerValidation.lastSuccessAt ?? listenerValidationHistory.lastSuccessAt, - }, + listener: listenerRouteHealth, tailscale: { enabled: tailscaleEnabled, tailscalePublished, diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 7752b93ed..dc4434491 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -1,3 +1,19 @@ +; nsExec::ExecToLog, not ExecToStack, on every PowerShell step below. +; +; ExecToStack buffers the child's entire output and hands it back only after the +; child exits, so for the whole of a step -- and step 1 registers and starts the +; per-user brain, which is not instant -- the page shows one stale status line +; and looks hung. ExecToLog DetailPrints each line as it arrives, and the +; InstFiles page mirrors the newest detail line into the status text above the +; progress bar, so the step visibly talks the whole time it runs. That status +; text is the surface that matters here: electron-builder's common.nsh sets +; `ShowInstDetails nevershow`, so the detail listbox itself is never on screen. +; +; The trade: ExecToLog pushes only the exit code, so a failing step no longer +; hands its message back for the MessageBox. Rather than point at a log the user +; cannot open, each failure modal reports the exit code and names the PowerShell +; install path, which runs the same work in a console where the error is visible. + !macro customInit Var /GLOBAL adeHadPreviousInstall StrCpy $adeHadPreviousInstall "0" @@ -8,19 +24,19 @@ !macroend !macro customInstall - DetailPrint "Configuring the ADE terminal command and per-user brain startup..." StrCpy $2 "stable" ${If} "${PRODUCT_NAME}" == "ADE Alpha" StrCpy $2 "alpha" ${ElseIf} "${PRODUCT_NAME}" == "ADE Beta" StrCpy $2 "beta" ${EndIf} - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-install-setup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + + DetailPrint "Step 1 of 2: configuring the ADE terminal command and starting the background service..." + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-install-setup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' Pop $0 - Pop $1 ${If} $0 != 0 - DetailPrint "$1" - MessageBox MB_ICONSTOP|MB_OK "ADE could not configure its terminal command or background startup.$\r$\n$\r$\n$1" + DetailPrint "Step 1 of 2 failed with exit code $0." + MessageBox MB_ICONSTOP|MB_OK "ADE could not configure its terminal command or background startup.$\r$\n$\r$\nThe setup step exited with code $0. Run the installer again. If it fails the same way, install from PowerShell with:$\r$\n$\r$\nirm https://ade-app.dev/install.ps1 | iex$\r$\n$\r$\nThat path prints the full error." ${If} $adeHadPreviousInstall != "1" DetailPrint "Rolling back the incomplete ADE product installation..." ExecWait '"$INSTDIR\${UNINSTALL_FILENAME}" /currentuser /S' $3 @@ -30,6 +46,7 @@ ${EndIf} Abort ${EndIf} + DetailPrint "Step 1 of 2 done." ; Pre-authorize the LAN sync listener so first run does not raise the Windows ; Firewall prompt. Windows only accepts firewall rules from an elevated @@ -37,18 +54,16 @@ ; false), so the script usually reports that it skipped the change instead of ; making one. Never fatal: a missing firewall rule costs one Windows prompt, ; it does not break the install. - DetailPrint "Pre-authorizing ADE local network sync in Windows Firewall..." - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action install -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + DetailPrint "Step 2 of 2: pre-authorizing ADE local network sync in Windows Firewall..." + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action install -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' Pop $0 - Pop $1 - DetailPrint "$1" ${If} $0 != 0 DetailPrint "ADE could not pre-authorize local network sync. Windows will ask once when you first use sync on this network." ${EndIf} + DetailPrint "Step 2 of 2 done." !macroend !macro customUnInstall - DetailPrint "Removing the ADE background service and terminal command..." StrCpy $2 "stable" ${If} "${PRODUCT_NAME}" == "ADE Alpha" StrCpy $2 "alpha" @@ -59,18 +74,21 @@ ; Take the inbound allowance back out before the product goes away, so an ; uninstall never leaves a rule pointing at a deleted executable. Same ; elevation caveat as install, and same non-fatal handling. - DetailPrint "Removing the ADE local network sync firewall rules..." - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action uninstall -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + DetailPrint "Step 1 of 2: removing the ADE local network sync firewall rules..." + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action uninstall -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' Pop $0 - Pop $1 - DetailPrint "$1" + ${If} $0 != 0 + DetailPrint "Firewall rule removal exited with code $0. Continuing the uninstall." + ${EndIf} + DetailPrint "Step 1 of 2 done." - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-uninstall-cleanup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + DetailPrint "Step 2 of 2: removing the ADE background service and terminal command..." + nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-uninstall-cleanup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' Pop $0 - Pop $1 ${If} $0 != 0 - DetailPrint "$1" - MessageBox MB_ICONSTOP|MB_OK "ADE could not remove its background service or terminal command. Close ADE and try uninstalling again.$\r$\n$\r$\n$1" + DetailPrint "Step 2 of 2 failed with exit code $0." + MessageBox MB_ICONSTOP|MB_OK "ADE could not remove its background service or terminal command. Close ADE and try uninstalling again.$\r$\n$\r$\nThe cleanup step exited with code $0." Abort ${EndIf} + DetailPrint "Step 2 of 2 done." !macroend diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 492cd5782..ddbbd0d2d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -333,12 +333,6 @@ } ], "icon": "build/icon.ico", - "signtoolOptions": { - "signingHashAlgorithms": [ - "sha256" - ], - "rfc3161TimeStampServer": "http://timestamp.digicert.com" - }, "artifactName": "ADE-${version}-win-${arch}.${ext}", "extraResources": [ { diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 24a5f4194..70fd148b9 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -762,6 +762,26 @@ test("the Windows install dialog keeps both install paths and their wording", () assert.match(installTargets, /MARKETING_FEATURES\.COPY_INSTALL_COMMAND_WINDOWS/); }); +test("the Windows install dialog warns about both OS prompts", () => { + // Neither prompt is a bug we can code away, so telling the user up front is + // the whole mitigation -- which makes this copy a support claim like the + // no-admin promise above, not decoration. + // + // SmartScreen: the Azure Trusted Signing certificate is a Public Trust + // (individual) profile, so it accrues publisher reputation rather than being + // trusted on sight the way an EV certificate is. A correctly signed build + // still warns until that reputation builds. + assert.match(installTargets, /Windows protected your PC/); + assert.match(installTargets, /Run anyway/); + // Firewall: Windows has no per-user firewall rule store and ADE installs + // per-user without elevation, so installer.nsh's netsh call cannot succeed + // and Windows raises its own allow dialog on first sync. See + // apps/desktop/scripts/windows-firewall-rules.ps1 -- it exits 0 on that path + // by design. + assert.match(installTargets, /Firewall/); + assert.match(installTargets, /private networks/); +}); + test("the /download/windows endpoint is routed and resolves the signed installer", () => { // The rewrite has to exist or the SPA catch-all swallows /download/windows // and the dialog's download button lands on the marketing page. diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index ba554ff6d..a04853d36 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -1159,7 +1159,7 @@ describe("RemoteTargetList", () => { expect(screen.queryByText(/token=secret/)).toBeNull(); }); - it("renders an info discovery diagnostic as muted text without a warning glyph", async () => { + it("keeps an info discovery diagnostic off the resting pane and shows it under Add machine", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], @@ -1177,12 +1177,19 @@ describe("RemoteTargetList", () => { render(); - const note = await waitFor(() => - screen.getByText("Tailscale not installed — LAN discovery only."), - ); + // A brand-new user's first read is the call to action, not an explanation + // of optional software they never asked about. + expect( + await screen.findByText("No computers yet. Choose Add machine to connect one."), + ).toBeTruthy(); + expect(screen.queryByText("Tailscale not installed — LAN discovery only.")).toBeNull(); + + // The information is still there, where someone is actually hunting for + // machines this list could be missing. + openAddMode("Find nearby computers"); + const note = await screen.findByText("Tailscale not installed — LAN discovery only."); // Not having optional software installed must not wear the warning glyph. expect(note.querySelector("svg")).toBeNull(); - expect(screen.getByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); }); it("surfaces Tailscale discovery warnings separately from empty results", async () => { diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 719a988b1..7d7b6c2da 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -411,9 +411,11 @@ export function RemoteTargetList({ } }, []); - // Warnings mean discovery is degraded and get the warning treatment. Info - // diagnostics ("Tailscale isn't installed") are normal on a plain Mac, so they - // render as muted secondary text with no warning glyph. + // Warnings mean discovery is degraded and get the warning treatment on the + // pane itself. Info diagnostics ("Tailscale isn't installed") are normal on a + // plain machine: they render as muted secondary text with no warning glyph, + // and only inside Add machine → Nearby, where the reader is actually looking + // for machines this list could be missing. const discoveryError = useMemo( () => discoveryFetchError ?? (joinDiagnosticMessages(discoveryDiagnostics, "warning") || null), [discoveryDiagnostics, discoveryFetchError], @@ -1274,6 +1276,12 @@ export function RemoteTargetList({ No computers found. Open ADE on the other computer and make sure both are on the same Wi-Fi or Tailscale network. ) : null} + {/* Info diagnostics ("Tailscale isn't installed") explain what is + missing from *this* list, so they belong here rather than on + the resting pane, where they used to push the "No computers + yet" call to action below two lines about a tool the reader + never asked for. */} + {discoveryNote ?

{discoveryNote}
: null} {nearbyMachines.map((machine) => ( ) : null} - {discoveryNote ?
{discoveryNote}
: null} - {loading ? (
{ }; render(); + // The brain's skipReason stays out of the card; the line says what is true. expect(screen.getByText( - "Signed in, but this computer is not published · The ADE brain is signed out of the ADE account.", + "Signed in — the ADE background service is signed out", )).toBeTruthy(); + expect(screen.queryByText(/The ADE brain is signed out of the ADE account\./)).toBeNull(); }); it("surfaces a missing Windows CR-SQLite runtime before pairing is attempted", () => { @@ -780,31 +782,80 @@ describe("accountDirectorySummary", () => { ); }); - it("names the publish failure reason instead of a bare state", () => { - const withState = ( - state: SyncRoleSnapshot["routeHealth"]["accountDirectory"]["state"], - skipReason: string | null, - ) => - accountDirectorySummary( - { - routeHealth: { - accountDirectory: { state, skipReason, reachableEndpointCount: 0 }, - }, - } as SyncRoleSnapshot, - true, - ); + const summaryForState = ( + state: SyncRoleSnapshot["routeHealth"]["accountDirectory"]["state"], + skipReason: string | null, + ) => + accountDirectorySummary( + { + routeHealth: { + accountDirectory: { state, skipReason, reachableEndpointCount: 0 }, + }, + } as SyncRoleSnapshot, + true, + ); - expect( - withState("token_unreadable", "The ADE brain could not read the stored account session."), - ).toEqual({ - label: - "Signed in, but this computer is not published · The ADE brain could not read the stored account session.", - healthy: false, - }); + it("never leaks the publisher's internal skipReason into user copy", () => { + // The regression: a real user was shown "Signed in, but this computer is + // not published · No active sync scope is available." — a fault report with + // no action in it. + const summary = summaryForState( + "no_active_sync_scope", + "No active sync scope is available.", + ); + expect(summary.healthy).toBe(false); + expect(summary.label).not.toContain("No active sync scope is available."); + // NOT "open a project". A brain with no project registered now publishes on + // its own, so the only remaining way to reach this state is another ADE + // process on this computer holding the machine-wide sync-host lease. + expect(summary.label).toBe( + "Signed in — another ADE app on this computer owns sync for this machine", + ); + expect(summary.label).not.toContain("open a project"); + }); - // No reason from the brain: the state itself is spelled out, not snake_case. - expect(withState("http_error", null).label).toBe( - "Signed in, but this computer is not published · http error", + it("gives each actionable publish state its own instruction", () => { + expect(summaryForState("account_signed_out", "The ADE brain is signed out.").label).toBe( + "Signed in — the ADE background service is signed out", + ); + expect(summaryForState("machine_key_unavailable", null).label).toBe( + "Signed in — this computer isn't registered yet", ); + expect(summaryForState("http_error", null).label).toBe( + "Signed in — can't reach your ADE account right now, retrying", + ); + }); + + it("falls back without a raw state string or a claim of permanence", () => { + const label = summaryForState("snapshot_failed", "Snapshot build failed: EPERM").label; + expect(label).toBe("Signed in — this computer isn't published yet"); + expect(label).not.toContain("snapshot_failed"); + expect(label).not.toContain("snapshot failed"); + expect(label).not.toContain("EPERM"); + }); + + it("keeps every unpublished state free of underscores and skipReason text", () => { + const states = [ + "sync_disabled", + "no_active_sync_scope", + "snapshot_failed", + "machine_key_unavailable", + "missing_pairing_connect_info", + "not_host", + "account_signed_out", + "token_unreadable", + "invalid_directory_url", + "http_error", + "token_timeout", + "http_timeout", + "timeout", + "transport_error", + ] as const; + for (const state of states) { + const { label, healthy } = summaryForState(state, "INTERNAL DIAGNOSTIC STRING"); + expect(healthy).toBe(false); + expect(label).not.toContain("INTERNAL DIAGNOSTIC STRING"); + expect(label).not.toContain("_"); + } }); }); diff --git a/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts b/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts index 9e6bc1bcd..6f6278a67 100644 --- a/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts +++ b/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts @@ -1,10 +1,31 @@ -import type { SyncRoleSnapshot } from "../../../shared/types"; +import { + describeUnpublishedAccountDirectory, + type SyncAccountDirectoryState, + type SyncRoleSnapshot, +} from "../../../shared/types"; export type AccountDirectorySummary = { label: string; healthy: boolean; }; +/** + * What to do about a machine that is signed in but not published. + * + * The per-state copy lives in `shared/types/sync.ts` beside the state union, so + * this pane and `ade setup` cannot drift apart the way their hand-mirrored + * copies already had. This function only decides how the shared advice reads on + * one line of the Connections pane. + */ +function unpublishedMachineLabel(state: SyncAccountDirectoryState): string { + // Only the summary. The shared table's `nextAction` is deliberately dropped + // here: `token_unreadable` already renders a Repair button beside this line, + // and the other actions are CLI commands, which mean nothing to someone + // reading a settings panel. + const { summary } = describeUnpublishedAccountDirectory(state); + return `Signed in — ${summary}`; +} + export function accountDirectorySummary( status: SyncRoleSnapshot, accountSignedIn: boolean, @@ -35,9 +56,5 @@ export function accountDirectorySummary( // plumbing detail the reader could neither act on nor interpret. return { label: "Connected to your ADE account", healthy: true }; } - const reason = health.skipReason ?? health.state.replaceAll("_", " "); - return { - label: `Signed in, but this computer is not published · ${reason}`, - healthy: false, - }; + return { label: unpublishedMachineLabel(health.state), healthy: false }; } diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 1d02498be..ffdd5bca3 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -371,6 +371,126 @@ export function isBrainAccountSessionFailure( return state === "token_unreadable"; } +const SYNC_ACCOUNT_DIRECTORY_STATES: readonly SyncAccountDirectoryState[] = [ + "published", + "sync_disabled", + "no_active_sync_scope", + "snapshot_failed", + "machine_key_unavailable", + "missing_pairing_connect_info", + "not_host", + "account_signed_out", + "token_unreadable", + "invalid_directory_url", + "http_error", + "token_timeout", + "http_timeout", + "timeout", + "transport_error", +]; + +/** + * Narrows a publisher state that arrived over RPC as an untyped string. + * + * The widening belongs here, at the trust boundary, rather than in a caller's + * signature where it would silently disable exhaustiveness checking on the + * switch below. + */ +export function isSyncAccountDirectoryState( + value: string | null | undefined, +): value is SyncAccountDirectoryState { + return typeof value === "string" + && (SYNC_ACCOUNT_DIRECTORY_STATES as readonly string[]).includes(value); +} + +export type UnpublishedMachineAdvice = { + /** What is true right now, in the user's terms. Never a raw diagnostic. */ + summary: string; + /** What clears it. Null when nothing the user does would help. */ + nextAction: string | null; +}; + +/** + * User-facing advice for a machine that is signed in but not published. + * + * One table, consumed by both the desktop Connections pane and `ade setup`. + * They previously kept hand-mirrored copies that had already drifted: the pane + * covered every state while the CLI covered one and printed the publisher's raw + * `skipReason` for the rest — the exact thing both files' comments condemned. + * + * Never render `skipReason` from this state. Those strings are internal + * diagnostics ("No active sync scope is available."); a real user saw one and it + * told them a fault had occurred and nothing about how to clear it. + */ +export function describeUnpublishedAccountDirectory( + state: SyncAccountDirectoryState, +): UnpublishedMachineAdvice { + switch (state) { + case "published": + return { summary: "published to your ADE account", nextAction: null }; + case "no_active_sync_scope": + // NOT "open a project" any more. A brain with no project registered now + // publishes on its own, so the only way to reach this state is another ADE + // process on this computer holding the machine-wide sync-host lease — and + // that process is the one publishing this machine. Telling the user to + // open a project would hand them an action that cannot change anything. + return { + summary: "another ADE app on this computer owns sync for this machine", + nextAction: "ade doctor", + }; + case "not_host": + return { + summary: "this computer publishes through your main ADE host", + nextAction: null, + }; + case "sync_disabled": + return { + summary: "sync is off on this computer, so other devices can't reach it", + nextAction: null, + }; + case "missing_pairing_connect_info": + return { + summary: "waiting for this computer's connection details", + nextAction: null, + }; + case "machine_key_unavailable": + return { + summary: "this computer isn't registered yet", + nextAction: "restart ADE", + }; + case "account_signed_out": + // The window has a session but the brain does not, so signing in again is + // what hands the brain one it can use. + return { + summary: "the ADE background service is signed out", + nextAction: "ade connect", + }; + case "token_unreadable": + // A Repair control renders beside this in the pane + // (isBrainAccountSessionFailure), so the summary names the fault and lets + // the button carry the verb. + return { + summary: "the ADE background service can't read your account session", + nextAction: "ade brain restart", + }; + case "http_error": + case "http_timeout": + case "token_timeout": + case "timeout": + case "transport_error": + return { + summary: "can't reach your ADE account right now, retrying", + nextAction: null, + }; + case "snapshot_failed": + case "invalid_directory_url": + return { + summary: "this computer isn't published yet", + nextAction: "restart ADE", + }; + } +} + export type SyncAccountDirectoryLegDurations = { snapshot: number | null; token: number | null; diff --git a/apps/web/src/components/install/InstallDialog.tsx b/apps/web/src/components/install/InstallDialog.tsx index 1eca66b02..35c02bee7 100644 --- a/apps/web/src/components/install/InstallDialog.tsx +++ b/apps/web/src/components/install/InstallDialog.tsx @@ -267,6 +267,26 @@ export function InstallDialog({ ) : null}
+ {/* Full width, below both columns: these prompts appear whichever + install path you took, so they cannot live in either column. */} + {target.prompts ? ( +
+

+ {target.prompts.heading} +

+
    + {target.prompts.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ) : null} +
{target.footnote.text ? (

diff --git a/apps/web/src/lib/installTargets.ts b/apps/web/src/lib/installTargets.ts index d3ecca211..4790a3f43 100644 --- a/apps/web/src/lib/installTargets.ts +++ b/apps/web/src/lib/installTargets.ts @@ -39,6 +39,17 @@ export type InstallTarget = { blurb?: string; options: InstallDownload[]; }; + /** + * OS prompts ADE cannot suppress, and what to click. Windows only today: the + * app installs per user with no elevation, so it can neither buy its way past + * SmartScreen's first-run warning nor pre-create a firewall rule (Windows has + * no per-user firewall rule store). Telling people up front is the fix that + * does not cost them a UAC prompt on every install. + */ + prompts?: { + heading: string; + items: string[]; + }; footnote: { text?: string; /** Copyable command rendered inside the footnote, when there is one. */ @@ -105,6 +116,13 @@ export const INSTALL_TARGETS: Readonly> = }, ], }, + prompts: { + heading: "Two Windows prompts to expect", + items: [ + "SmartScreen may say “Windows protected your PC”. Choose More info, then Run anyway.", + "Windows Firewall asks to allow ADE the first time you sync over your network. Allow it on private networks. A per-user install cannot pre-approve that rule.", + ], + }, footnote: { text: "Per-user installer — no administrator rights. Windows 10/11, x64.", }, diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 38b0bd803..22c386774 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -426,8 +426,12 @@ relay payload E2E encryption is planned security work. See the trust boundary in `ade-` SEA binary and the `.native.tar.gz` of native modules, resolves the runtime version from the CLI / desktop package metadata, and verifies same-platform static binaries report that version. -- `apps/ade-cli/scripts/install-runtime.sh` — standalone installer that - downloads `ade-` and the matching native deps from a release. +- `apps/ade-cli/scripts/install-runtime.sh` and `install-runtime.ps1` — + standalone installers that download `ade-` and the matching + native deps from a release. They own only what must happen before the `ade` + binary exists — download, verify, PATH, `ade brain start` — and hand the rest + of onboarding to `ade setup`, one implementation both platforms share so they + cannot drift. - `apps/desktop/scripts/materialize-runtime-resources.mjs` and `validate-runtime-resources.mjs` — populate and validate `apps/desktop/resources/runtime/` for packaging. @@ -759,7 +763,8 @@ These are the promoted URLs; `apps/web/api/install.ts` serves them by proxying t - installs the binary to `$ADE_INSTALL_DIR` (default `$ADE_HOME/bin`), - extracts the native modules to `$ADE_HOME/runtime//`, - verifies with `ade --version`, -- best-effort registers the per-user login service via `ade serve --install-service` on macOS and systemd Linux. +- best-effort registers the per-user login service via `ade brain start` on macOS and systemd Linux — not `serve --install-service`, which inherits an unset `ADE_DEFAULT_ROLE`, registers the brain at role `agent`, and makes `ade connect` fail on every clean install. +- hands off to `ade setup` for everything after that: pinned agent CLIs, account, desktop app, end-to-end verification, and a closing summary. The same command re-runs the flow at any time. Environment overrides: @@ -773,7 +778,7 @@ After install, the headless machine can already serve clients. Desktop ADE on a There are two ways to make that machine reachable, and they are independent: - **SSH remote target** — the desktop bootstraps and tunnels to it. No account involved. Covered by the rest of this document. -- **Account-published machine** — run `ade connect` (or `ade connect --headless` over SSH, where no browser is available) on the box itself. It signs in, installs the per-user login service, and waits for the machine's row to reach the account directory, after which desktop, the web client, and iOS can all reach it without SSH. The install scripts offer to run this for you at the end. See `apps/ade-cli/README.md` §"ADE account auth" for the three-step contract and why the brain must stay running for the machine to stay published. +- **Account-published machine** — run `ade connect` (or `ade connect --headless` over SSH, where no browser is available) on the box itself. It signs in, installs the per-user login service, and waits for the machine's row to reach the account directory, after which desktop, the web client, and iOS can all reach it without SSH. The install scripts run this for you as `ade setup`'s account step. No project is required: a headless box with an empty `~/.ade/projects.json` publishes itself and dials the relay as soon as its brain holds the machine sync-host lease. See `apps/ade-cli/README.md` §"ADE account auth" for the three-step contract and why the brain must stay running for the machine to stay published. Headless hosts update through the same binary, without requiring the desktop app: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 496f59f09..ee01aa890 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -114,6 +114,56 @@ stop and restart the machine's relay tunnel and tear down and rebuild the directory publisher (`ade doctor` would report "Account-directory publishing has not started" in the gap). +### Hosting sync, and publishing, with no project + +A project is not a precondition for anything machine-level. A brain that holds +the lease and has the shared listener bound hosts phone sync, publishes itself +to the account directory, and dials the relay with nothing in +`~/.ade/projects.json`. That is the normal state of a headless box and of any +machine between the installer finishing and the user opening their first +repository. + +`projectlessSyncSnapshot.ts` builds the `SyncRoleSnapshot` for that state. +Hosting is the lease **and** a bound port: a runtime that bound a listener +without winning the lease is not the machine's sync host and reports the honest +all-down shape (`buildDegradedProjectlessSyncSnapshot`) instead of claiming a +host that does not exist. When it is hosting, the snapshot carries the real +listener port, the machine identity read from `~/.ade/secrets/sync-device-id` / +`sync-site-id`, real `pairingConnectInfo`, and real relay route health. + +Those last two fields are why this matters beyond diagnostics. The +account-directory publisher gates on `listenerBound` and `pairingConnectInfo`, +so a projectless brain fed a hardcoded all-down placeholder could never publish +itself — a signed-in machine with an empty project registry simply never +appeared in the user's account, and `ade doctor` reported it unreachable while +it was serving phones fine. `runServe`'s publisher `getSnapshot` now falls back +to the projectless builder whenever this brain holds the lease, and returns null +only when it genuinely does not. + +Two consequences follow for copy anywhere in the product: + +- **"Open a project" is never the fix for an unpublished machine.** It was + before; it is not now. The `no_active_sync_scope` publisher state is now only + reachable when *another* ADE process on this computer holds the machine-wide + lease — and that process is the one publishing the machine. Telling the user + to open a project would hand them an action that cannot change anything. The + per-state advice lives in one table, + `describeUnpublishedAccountDirectory` in `apps/desktop/src/shared/types/sync.ts`. +- **A projectless brain still has no project-scoped state to report.** Runtime + name, pairing PIN, and Tailscale Serve publication belong to a project scope, + so the snapshot reports them absent rather than inventing them. Publication to + the account directory deliberately does not require a pairing PIN: account + membership is the auth path, and the PIN is the fallback for nearby devices + that are not signed in. + +The relay half is symmetric. `createAdeRuntime` builds the relay tunnel per +project scope, so with no scope nothing dialed it and such a machine was +LAN-only. `machineRelayTunnel.ts` is now the one construction both paths use; +`runServe` builds it lazily on the same event that takes the projectless lease. +Relay is an extra route, never a precondition for hosting sync, so a failure to +build it is logged (`sync.projectless_relay_start_failed`) and startup +continues. + ## Who participates - **Machine runtime** — the per-channel, per-machine `ade serve` runtime. It owns agent @@ -366,7 +416,12 @@ Runtime support files outside `services/sync/`: `SYNC_HOST_AUTHORITY_RELEASE_GRACE_MS` (a project switch never qualifies); the publisher cannot be restarted after dispose, so a later lease acquisition builds a fresh one. A second brain publishing its own endpoints would point - phones at a runtime that does not host sync. The published + phones at a runtime that does not host sync. Its snapshot source is the active + project scope's `syncService.getStatus()` when there is one and + `buildProjectlessSyncSnapshot` when there is not, so a machine with an empty + project registry publishes on the same terms as any other — see *Hosting sync, + and publishing, with no project*. It reports `no_active_sync_scope` only when + this brain does not hold the lease at all. The published machine `name` is suffixed by package channel (`publishedMachineName`): a Beta build advertises ` · Beta` and an Alpha build ` · Alpha`, while a stable build (or an already-suffixed name) is left untouched, so the same @@ -515,7 +570,13 @@ Runtime support files outside `services/sync/`: probe, or returns a skipped verdict when no host is active), `runtimeEvents.*`, project-scoped `ade/actions/call`, and project-independent `personalChats.call` / - `personalChats.streamEvents`. Runtime-event subscribe replies include the gap + `personalChats.streamEvents`. `sync.getStatus` with no active scope answers + from the injected `getProjectlessSyncSnapshot` — the brain passes the real + builder, and a process that has none falls back to + `buildDegradedProjectlessSyncSnapshot` — rather than the fixed all-down + literal it used to return, which misreported a hosting brain as unreachable + to `ade doctor` and to the account-directory publisher. + Runtime-event subscribe replies include the gap fields above; `projects.list` resolves at most 24 host-side icons within 750 ms, with 128 KiB per-icon and 512 KiB aggregate wire caps, so large project registries cannot stall remote desktop or mobile catalog setup just @@ -545,6 +606,24 @@ Runtime support files outside `services/sync/`: Desktop connection UI: +- `apps/desktop/src/shared/types/sync.ts` — the account-directory state union + plus the two things every consumer of it needs: + `isSyncAccountDirectoryState`, which narrows a state that arrived over RPC as + an untyped string (widening at the trust boundary rather than in a caller's + signature, where it would silently disable the exhaustiveness check), and + `describeUnpublishedAccountDirectory`, the per-state + `{ summary, nextAction }` advice for a machine that is signed in but not + published. That table is the **only** place this copy lives; the Connections + pane and `ade setup` both read it. They previously kept hand-mirrored copies + that had already drifted — the pane covered every state, the CLI covered one + and printed the publisher's raw `skipReason` for the rest. Never render + `skipReason` to a user: those strings are internal diagnostics ("No active + sync scope is available.") that name a fault and say nothing about clearing + it. `nextAction` is a CLI command, so a surface that has a button for the + same fix (the pane's **Repair** control for `token_unreadable`) drops it and + renders the summary alone. +- `apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts` — + turns that advice into the one Connections line: `Signed in —

`. - `apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx` — hosts the `relay-offline` banner alongside the GitHub/AI-provider family. `AppShell` seeds `routeHealth.relay` from `sync.getLocalStatus` (the physical @@ -573,12 +652,15 @@ Desktop connection UI: generate and set a new six-digit PIN instead of leaving copy disabled. Initial-load failures show a short recovery action while keeping the raw message under **Technical details**: missing project registration asks the - user to open a project, a non-installed local release build asks for an - Applications install/relaunch, and other sync-service failures ask for an ADE - restart. When the account-directory state is the one failure a restart + user to open a project, a build running from its output directory asks the + user to install this build and reopen it from the installed copy — that copy + names no folder, because the macOS Applications folder it used to name does + not exist on Windows — and other sync-service failures ask for an ADE + restart. When + the account-directory state is the one failure a restart actually clears — `isBrainAccountSessionFailure(...)` in - `shared/types/sync.ts`, currently exactly `token_unreadable` — the This Mac - card renders a **Repair** control next to the directory summary. The local-brain-only + `shared/types/sync.ts`, currently exactly `token_unreadable` — the This + computer card renders a **Repair** control next to the directory summary. The local-brain-only `window.ade.sync.getLocalStatus(...)` accessor is available for the card to consume so a window bound to another machine can still show the physical computer's identity, pairing code, and Phone/Web device lists. @@ -734,7 +816,44 @@ Canonical files (`apps/ade-cli/src/services/sync/`): hand a phone a catalog and react to `project_switch_request`. Accepts `forceHostRole` only as a legacy override; normal callers leave it false so a second runtime becomes a viewer instead of stealing the - sync authority role. + sync authority role. Its route-health derivation lives in + `syncRouteHealth.ts`, shared with the projectless path. +- `syncRouteHealth.ts` — `deriveListenerHealth` and `buildRelayRouteHealth`, + the one derivation of how a machine describes its own inbound routes. Both + `syncService.getStatus` (project scope) and `buildProjectlessSyncSnapshot` + (no scope) call it with the same raw inputs. The strings are what the user + reads in Connections and `ade doctor`, and the account-machine publisher gates + on the booleans, so the two paths disagreeing is not cosmetic drift — it is + one of them lying about whether the machine is reachable. The genuine + differences are parameters, not branches: what "not bound" means (a listener + that failed to start vs a machine with no scope), and which timestamp stands + in when the tunnel reports no failure time. A loopback validation result + counts only while it still names the currently bound port, so a rebound + listener is never reported healthy from a stale probe. +- `projectlessSyncSnapshot.ts` — the `SyncRoleSnapshot` for a brain with no + project scope. See *Hosting sync, and publishing, with no project*. + `buildProjectlessSyncSnapshot` reports the real listener, machine identity, + pairing connect info, and relay when this process holds the lease and has the + shared listener bound; `buildDegradedProjectlessSyncSnapshot` is the honest + all-down shape for callers with neither. It replaced a hardcoded placeholder + that claimed `listenerBound: false` and `pairingConnectInfo: null` for a + genuinely bound listener — the two fields the account-directory publisher + gates on. +- `machineRelayTunnel.ts` — `createMachineRelayTunnel`, the machine's one relay + tunnel client plus its authority gate. Both brains that can host phone sync + build it: `createAdeRuntime` for a project scope, and `runServe`'s projectless + path for a machine with nothing registered. They must produce the same thing, + because the relay Durable Object keeps one host control socket per + `machineKey` and evicts the previous holder with `4505` — two clients on one + machine evict each other in a loop and relay stays down for both. The client + is cached one-per-machine keyed by the relay config path, so a project scope + booting after the projectless brain adopts that brain's client instead of + registering the same `machineKey` twice. The host listener is attached + *outside* the factory and before the gate: whichever runtime actually owns the + listener wins regardless of who created the instance, and the gate's first + `start()` already has a bridge to validate. Only the reaction to a + publication-state change differs between the two callers, so that stays a + parameter. - `syncHostService.ts` — the per-project WebSocket host. Owns connection acceptance, hello/pairing handshakes (an `auth_failed` rejection is attributed with the rejecting machine's @@ -1095,6 +1214,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `ComputerName` (`scutil`) and the Tailscale DNS name refresh asynchronously; Tailscale status is single-flight and retained for 30 seconds so periodic machine publication cannot block the brain event loop on an external CLI. + The identity defaults are exported as `localSyncDeviceDefaults()` so + `projectlessSyncSnapshot` can name the machine without a project database. + The Tailscale probe spawns with `windowsHide`, so a packaged Windows brain + never flashes a console window on its 30-second cadence. - `syncPairingStore.ts` — validates `pairing_request` envelopes against `syncPinStore`, mints the durable per-device secret, and persists it into the `paired_devices` row (SQLite). Each @@ -2378,6 +2501,7 @@ feature is merged or because a deliberately isolated-port host is running. | Cloud tunnel relay (off-LAN transport, `relay` candidate) | Implemented whenever the host is signed in, with no separate toggle and with same-account per-connection proof (`syncTunnelClientService` + `apps/tunnel-relay`) | | Relay end-to-end self-probe + zombie-control detection (honest relay publication) | Implemented (`syncRelaySelfProbe`, JSON control keepalive, `sync.runSelfProbe`, `ade doctor` relay check) | | Relay tunnel + account-directory publisher gated on the machine sync-host lease | Implemented (`syncHostSingleton` authority registry, `relayTunnelAuthorityGate`, `runServe` publisher gate) | +| Account publication + relay for a machine with no registered project | Implemented (`projectlessSyncSnapshot`, `machineRelayTunnel`, `runServe` publisher snapshot fallback) | | Relay eviction (`4505`) suppression + surfaced outage | Implemented (bounded re-attempts, 10-minute re-arm, `routeHealth.relay.relayControlSuppressed*`, `ade doctor` relay row, desktop `relay-offline` banner) | | Sealed account adoption over direct routes (`ade-adopt-v1`, host `pubkey` identity, LAN → tailnet → Relay fallback, negotiated ChaCha20-Poly1305 / AES-256-GCM AEAD) | Implemented (`machineIdentitySigningStore` + `adoptChannelCrypto`; desktop + iOS clients) | | Legacy manual-pairing adoption into an account (DPoP-gated) + `localTrustOrigin` demotion on sign-out | Implemented (`syncPairingStore.pairPeerViaAccount` / `revokeAccountOwnedExcept`, `syncHostService` account hello) | @@ -2415,6 +2539,12 @@ feature is merged or because a deliberately isolated-port host is running. authority subscription), and must tolerate the momentary `false` that a project switch produces by riding it out for `SYNC_HOST_AUTHORITY_RELEASE_GRACE_MS` rather than reacting on the edge. +- **A project is not a precondition for anything machine-level.** Hosting phone + sync, publishing to the account directory, and dialing the relay all gate on + the sync-host lease plus a bound shared listener — never on an open or + registered project. Copy that tells a user to open a project in order to link + or publish a machine is wrong, and per-state advice belongs in + `describeUnpublishedAccountDirectory` rather than in each surface. - **`ADE_ENABLE_DESKTOP_SYNC_HOST` is a diagnostics escape hatch.** If you turn it on, both an in-process host and the standing runtime can be alive simultaneously on the same machine — that's intentional for From 5905b226be6cc97ae130173dd866c83a42b7e3fe Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Wed, 5 Aug 2026 18:50:26 -0400 Subject: [PATCH 4/4] fix(setup): truncate the live step line by visible width, not bytes CodeRabbit flagged this twice and both review passes refuted it: nothing reaching `formatActiveLine` is coloured today, because `stateSymbol` only paints the `ok` and `failed` symbols and that line passes a literal "active". Measuring with `String.length` is therefore correct -- by coincidence, not by construction. That is a poor invariant to ship. Colour one more component of the line later and it silently truncates early, and a slice through a reset sequence leaves the colour applied to everything printed afterwards. Truncation now counts columns actually occupied and copies escape sequences whole. Writing the test caught a bug in the first version of this: breaking out of the loop once the visible budget was spent dropped the trailing reset, which is precisely the bleed it was meant to prevent. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/commands/setup.test.ts | 25 ++++++++++++ apps/ade-cli/src/commands/setupRender.ts | 48 +++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/commands/setup.test.ts b/apps/ade-cli/src/commands/setup.test.ts index eb3f2d4b0..c845be9b9 100644 --- a/apps/ade-cli/src/commands/setup.test.ts +++ b/apps/ade-cli/src/commands/setup.test.ts @@ -913,3 +913,28 @@ describe("describeUnpublishedMachine", () => { } }); }); + +describe("truncateToVisibleWidth", () => { + const ESC = String.fromCharCode(27); + + it("measures columns occupied, not bytes, so colour does not truncate early", async () => { + const { truncateToVisibleWidth, visibleWidth } = await import("./setupRender"); + const coloured = `${ESC}[32mOK${ESC}[0m plain text`; + expect(visibleWidth(coloured)).toBe("OK plain text".length); + // Byte length is far larger; a length-based cut would lose real characters. + expect(truncateToVisibleWidth(coloured, 13)).toContain("plain text"); + }); + + it("never cuts inside an escape, so colour cannot bleed into later output", async () => { + const { truncateToVisibleWidth } = await import("./setupRender"); + const out = truncateToVisibleWidth(`${ESC}[32mABCDEFGH${ESC}[0m`, 3); + // Both sequences survive whole; only visible characters are dropped. + expect(out).toBe(`${ESC}[32mABC${ESC}[0m`); + expect(out.endsWith(`${ESC}[0m`)).toBe(true); + }); + + it("leaves a line that already fits completely alone", async () => { + const { truncateToVisibleWidth } = await import("./setupRender"); + expect(truncateToVisibleWidth("short", 80)).toBe("short"); + }); +}); diff --git a/apps/ade-cli/src/commands/setupRender.ts b/apps/ade-cli/src/commands/setupRender.ts index 2f7f8220a..f44a0fedf 100644 --- a/apps/ade-cli/src/commands/setupRender.ts +++ b/apps/ade-cli/src/commands/setupRender.ts @@ -200,7 +200,53 @@ export function formatActiveLine( : ` ${parts.join(" ")}`; // Never wrap: a wrapped line cannot be erased by a single clear-line, which // would leave orphaned progress bars scrolling up the screen. - return line.length > caps.columns ? line.slice(0, caps.columns - 1) : line; + return truncateToVisibleWidth(line, caps.columns - 1); +} + +/** Matches a CSI sequence, which occupies no columns on screen. */ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); + +export function visibleWidth(text: string): number { + return text.replace(ANSI_PATTERN, "").length; +} + +/** + * Truncate by columns actually occupied, never mid-escape. + * + * Nothing reaching `formatActiveLine` is coloured today -- `stateSymbol` only + * paints the `ok` and `failed` symbols and that line passes a literal + * `"active"`. Measuring by `String.length` therefore happens to be right, which + * is exactly the problem: it is right by coincidence. Colour one more component + * later and the line silently truncates early, and a naive slice through a reset + * sequence leaves the colour bleeding into everything printed afterwards. + */ +export function truncateToVisibleWidth(text: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + if (visibleWidth(text) <= maxWidth) return text; + + let out = ""; + let width = 0; + ANSI_PATTERN.lastIndex = 0; + for (let index = 0; index < text.length;) { + ANSI_PATTERN.lastIndex = index; + const match = ANSI_PATTERN.exec(text); + if (match?.index === index) { + // Escapes are copied whole and cost no width, so a trailing reset still + // makes it out even once the visible budget is spent. + out += match[0]; + index += match[0].length; + continue; + } + // Past the visible budget we keep scanning rather than breaking: stopping + // here would drop the trailing reset and leave the colour applied to + // everything printed afterwards, which is the failure this exists to avoid. + if (width < maxWidth) { + out += text[index]; + width += 1; + } + index += 1; + } + return out; } /** A finished step, as it scrolls away: `+ Agent CLIs codex, claude-code`. */