diff --git a/CLAUDE.md b/CLAUDE.md index 5222e83..47558a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,16 @@ The PowerShell profile (`powershell/Microsoft.PowerShell_profile.ps1`) is a sing `Microsoft.PowerShell_profile.ps1` is structured as **three phases** to keep startup fast: -1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), dot-source `Profile/AzCliAccount.ps1` followed by an explicit `Restore-AzActiveProfile` (az CLI account switch — see below), aliases, tool wrapper functions (`y` for yazi). +1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), dot-source `Profile/AzCliAccount.ps1` followed by an explicit `Restore-AzActiveProfile` (az CLI named-profile switch — one state-file read, see below), aliases, tool wrapper functions (`y` for yazi). 2. **Phase 2a (first idle)**: `Initialize-DeferredProfile` loads PSFzf and zoxide, upgrades PSReadLine to `HistoryAndPlugin`, sets the fd-backed `FZF_*_COMMAND` vars, defines the eza `ll`/`la`/`lt` helpers, and calls `Initialize-AzTimer` (Az context timer — `runspace.Open()` + first eventing call ~200ms, moved off the load path). Guarded by `$global:ProfileDeferredDone`. These are the interactive tools needed immediately after the prompt appears. 3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers, defines the `prr`/`Invoke-AdoPrReview` ADO PR review helper (az-gated: REST-backed fzf PR picker — `ConvertFrom-AdoRemoteUrl` + a session-cached az bearer token (`Get-AdoAccessToken`), ~0.2s warm vs ~2-3s for `az repos pr list` — → detached `review` worktree → `nvim "+AdoPrReview "`, pairing with ado-pr.nvim), and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. 4. **Phase 3 (async runspace)**: Az context refresh on a 60s timer. The timer is created in Phase 2a (`Initialize-AzTimer`); the refresh itself runs in a background runspace wired up in `Set-Prompt.ps1`. -**az CLI account switch (`Profile/AzCliAccount.ps1`, `azw`/`azp`)**: `Switch-AzWork` / `Switch-AzPersonal` flip `az` between the work account (default `~/.azure`, `AZURE_CONFIG_DIR` **unset**) and the personal account (`~/.azure-personal`, `AZURE_CONFIG_DIR` set) — asymmetric on purpose so the existing work login is never disturbed. The file **defines functions only, no load-time side effects** (mirroring how `Set-Prompt.ps1` defines `Initialize-AzTimer` but the profile calls it); Phase 1 dot-sources it and then calls `Restore-AzActiveProfile`, which reads the `~/.azure-active-profile` state file and re-applies the last-used account (a `work`/missing/empty/unrecognized state **actively unsets** `AZURE_CONFIG_DIR`, never merely skips, so an inherited value can't survive). Each switch **clears `$global:__AdoAccessToken`** so `prr`/`Invoke-AdoPrReview` (Phase 2b) re-authenticates against the now-active account instead of listing the previous account's PRs from the session-cached token. The active account is **always shown in the prompt** — `Set-Prompt.ps1`'s `Get-AzCliAccountSegment` renders an `az:work`/`az:personal` tag on the context line from `$env:AZURE_CONFIG_DIR` alone (no `az` process), so a new shell's silent `Restore-AzActiveProfile` is still visible at a glance. Covered by `tests/AzCliAccount.Tests.ps1` and `tests/Set-Prompt.Tests.ps1`. +**az CLI named-profile switch (`Profile/AzCliAccount.ps1`, `azs`)**: `Switch-AzProfile` (alias `azs`) points `AZURE_CONFIG_DIR` at `~/.azure-profiles/` — every profile is a self-contained config dir, symmetric, with no profile defined by absence. **One identity per profile dir is the whole point**: `az devops` builds its auth candidate list from the config dir's cached subscriptions and returns the first identity that can list *any* project, so a dir holding two logins can silently authenticate as an account you did not select, with no signal at the shell. The name is exactly the leaf of `$env:AZURE_CONFIG_DIR`, validated against `^[A-Za-z0-9][A-Za-z0-9_-]*$` (that regex is also the path-injection guard, since the name becomes a directory). `default` is reserved: it means **unset** `AZURE_CONFIG_DIR`, i.e. az's own `~/.azure`, and creates no directory. + +**Rejected alternatives — do not reintroduce.** *Junction/symlink `~/.azure` at the active profile*: mechanically the best option (retargeting a reparse point is one metadata write and succeeds with open handles — verified), but on Windows `~/.azure` is **one case-insensitive directory shared with Azure PowerShell** (`AzureRmContext.json` sits beside `azureProfile.json`), so junctioning it would silently switch every `Get-Az*` too. Az PowerShell is out of scope; `~/.azure` stays untouched. *Directory swapping*: `Move-Item` on a directory is copy-then-delete, not atomic, and leaves torn state (verified: a failed swap emptied the destination while the source still held the payload); true `[IO.Directory]::Move` fails outright while any file inside is open, and `az` holds `msal_token_cache.bin` open on every command — and it makes the active account machine-global mutable state. *Discovery by a `~/.azure-*` glob*: the home dir already contains `.azure-devops`, `.azure-functions-core-tools`, and `.azurefunctions`, which a glob would offer as profiles — hence a container dir. *A manifest file listing profiles*: a second source of truth that drifts from the dirs actually holding the credentials. **The directories are the only registry** — adding a profile is "switch to it and log in", never "edit a file". Do not add one "for robustness". + +The file **defines functions only, no load-time side effects** (mirroring how `Set-Prompt.ps1` defines `Initialize-AzTimer` but the profile calls it); Phase 1 dot-sources it and calls `Restore-AzActiveProfile`, whose cost budget is **exactly one `~/.azure-active-profile` read — no enumeration, no JSON**; all discovery lives in the picker. A `default`/missing/empty/invalid state **actively unsets** `AZURE_CONFIG_DIR`, never merely skips, so an inherited value can't survive. It also sets **`AZURE_EXTENSION_DIR` unconditionally and first** at one shared store (`~/.azure/cliextensions`): the extension store must be decoupled from the config dir — verified: with a fresh `AZURE_CONFIG_DIR` and no override, `az extension list` is empty, so every non-default profile loses `az devops` and `az graph` outright. Bare `azs` opens the **fzf picker** (bare `fzf` in a native pipeline, *not* PSFzf's `Invoke-Fzf`, whose redirected-stdout process launcher desyncs under psmux's ConPTY); each candidate's identity is precomputed from that dir's `azureProfile.json` into a tab-delimited field so the preview is a bare `echo` — never a `pwsh` or `az` process per keystroke, and it still works logged out. Only an *omitted* `-Name` picks interactively; `azs ''` throws. Each switch **clears `$global:__AdoAccessToken`** so `prr`/`Invoke-AdoPrReview` (Phase 2b) re-authenticates against the now-active account instead of listing the previous account's PRs from the session-cached token. The active profile is **always shown in the prompt** — `Set-Prompt.ps1`'s `Get-AzCliAccountSegment` renders `az:` (or `az:default` when unset) on the context line from `$env:AZURE_CONFIG_DIR` alone (no `az` process), so a new shell's silent `Restore-AzActiveProfile` is still visible at a glance. Covered by `tests/AzCliAccount.Tests.ps1` and `tests/Set-Prompt.Tests.ps1`. When adding to the profile, classify by cost first — anything that loads .NET assemblies or scans the filesystem belongs in Phase 2, not Phase 1. Use the `$global:ProfileModules` cache rather than calling `Get-Module -ListAvailable` again. diff --git a/powershell/Microsoft.PowerShell_profile.ps1 b/powershell/Microsoft.PowerShell_profile.ps1 index 15eec47..000ef50 100644 --- a/powershell/Microsoft.PowerShell_profile.ps1 +++ b/powershell/Microsoft.PowerShell_profile.ps1 @@ -117,10 +117,11 @@ if ($global:ProfileInteractiveConsole) { # --- Prompt ----------------------------------------------------------------- . "$(Split-Path -Path $PROFILE)/Profile/Set-Prompt.ps1" -# --- az CLI account switch (azw/azp) ---------------------------------------- -# Defines functions only (no load-time side effects); the explicit Restore call -# below applies the persisted last-used account. Cheap enough for Phase 1 — one -# state-file read plus an env-var set. +# --- az CLI named-profile switch (azs) -------------------------------------- +# Defines functions and the azs alias only (no environment, filesystem, or +# process side effects at dot-source); the explicit Restore call below applies +# the persisted last-used profile. Cheap enough for Phase 1 — one state-file +# read plus env-var sets. . "$(Split-Path -Path $PROFILE)/Profile/AzCliAccount.ps1" Restore-AzActiveProfile diff --git a/powershell/Profile/AzCliAccount.ps1 b/powershell/Profile/AzCliAccount.ps1 index 6e5b546..07f5875 100644 --- a/powershell/Profile/AzCliAccount.ps1 +++ b/powershell/Profile/AzCliAccount.ps1 @@ -1,11 +1,76 @@ # ============================================================================ -# AzCliAccount.ps1 — switch the Azure CLI between PERSONAL and WORK accounts +# AzCliAccount.ps1 — switch the Azure CLI between NAMED profiles # ============================================================================ -# Defines functions only; NO side effects at dot-source. The profile calls -# Restore-AzActiveProfile explicitly in Phase 1 (mirroring how Set-Prompt.ps1 -# defines Initialize-AzTimer but the profile calls it). +# Each profile is a self-contained AZURE_CONFIG_DIR under ~/.azure-profiles/, +# so a dir never caches more than one identity. Defines functions only; NO side +# effects at dot-source. The profile calls Restore-AzActiveProfile explicitly in +# Phase 1 (mirroring how Set-Prompt.ps1 defines Initialize-AzTimer but the profile +# calls it). # ============================================================================ +function Test-AzProfileName { + <# + .SYNOPSIS + True when the string is a usable az profile name. Also the path-injection guard: + a name becomes a directory under ~/.azure-profiles, so separators and traversal + must never pass. Pure string test — no filesystem access, safe to call from the + Phase 1 restore path. + #> + [OutputType([bool])] + param([string]$Name) + + return $Name -match '^[A-Za-z0-9][A-Za-z0-9_-]*$' +} + +function Get-AzProfileIdentity { + <# + .SYNOPSIS + The identity cached in a profile's azureProfile.json, for the picker's preview column. + .DESCRIPTION + A plain file read — never an `az` process — so the preview costs nothing per keystroke + and works while logged out. A missing, unreadable, or unparsable file yields the login + hint rather than an error: the picker must list a profile that has never been used. + #> + [OutputType([string])] + param([Parameter(Mandatory)][string]$Name) + + [string]$notLoggedIn = 'not logged in — run: az login' + [string]$dir = if ($Name -eq 'default') { + Join-Path $HOME '.azure' + } else { + Join-Path $HOME '.azure-profiles' $Name + } + [string]$file = Join-Path $dir 'azureProfile.json' + if (-not (Test-Path -LiteralPath $file)) { return $notLoggedIn } + + try { + $parsed = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json + } catch { + return $notLoggedIn + } + + [object[]]$subscriptions = @($parsed.subscriptions) + if (-not $subscriptions) { return $notLoggedIn } + + # Users first — that is the identity the one-identity-per-profile rule is about. A + # service-principal / managed-identity login writes no user.name, so fall back to the + # subscription names rather than mislabelling a real login as logged out. + [string[]]$users = @($subscriptions.user.name.Where({ $_ }) | Select-Object -Unique) + [string]$identity = if ($users) { $users -join ', ' } else { '' } + + # .Where(..., 'First') short-circuits on the first match and yields an EMPTY + # collection (not $null) when nothing matches — so test it for truthiness below, + # never with -eq $null, and do not index [0] (that throws under StrictMode). + $active = $subscriptions.Where({ $_.isDefault }, 'First') + [string]$subscription = if ($active) { $active.name } else { $subscriptions[0].name } + if ($subscription) { + $identity = if ($identity) { "$identity — $subscription" } else { $subscription } + } + + if (-not $identity) { return $notLoggedIn } + return $identity +} + function Show-AzAccountStatus { <# .SYNOPSIS @@ -25,68 +90,123 @@ function Show-AzAccountStatus { } } -function Switch-AzPersonal { +function Select-AzProfileName { <# .SYNOPSIS - Switch the Azure CLI to the PERSONAL account by pointing AZURE_CONFIG_DIR at an - isolated per-account config dir (~/.azure-personal), so its token cache never - mixes with the work login under the default ~/.azure. + Pick a profile interactively with fzf, showing each candidate's cached identity. + Returns the picked name, or an empty string when the pick is cancelled. .DESCRIPTION - Persists the choice to the ~/.azure-active-profile state file so a new shell - restores it, clears the session-cached ADO bearer token (so prr re-authenticates - against this account), then announces the switch and — best effort — the active - account. Aliased as azp. + The ONLY discovery-by-enumeration path — Restore-AzActiveProfile must never call it. + Identities are precomputed into a tab-delimited second field so the preview is a bare + echo: no pwsh or az process is spawned per keystroke. #> - $dir = Join-Path $HOME '.azure-personal' - $env:AZURE_CONFIG_DIR = $dir - Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'personal' - # Force prr/Invoke-AdoPrReview to re-fetch a token for the now-active account - # instead of listing the previous account's PRs from the stale cached token. - $global:__AdoAccessToken = $null - Write-Host "→ az account → personal ($dir)" -ForegroundColor Magenta - Show-AzAccountStatus + [OutputType([string])] + param() + + if (-not (Get-Command fzf -ErrorAction Ignore)) { + throw 'fzf was not found on PATH, so the az profile picker cannot run. Install fzf, or name the profile explicitly: Switch-AzProfile -Name .' + } + + [string]$container = Join-Path $HOME '.azure-profiles' + [string[]]$names = @('default') + if (Test-Path -LiteralPath $container) { + $names += @(Get-ChildItem -LiteralPath $container -Directory | Select-Object -ExpandProperty Name) + } + + # Bare fzf (native pipeline), NOT PSFzf's Invoke-Fzf: its redirected-stdout + # System.Diagnostics.Process launcher desyncs under psmux's ConPTY. + [string]$picked = $names | + ForEach-Object { "$_`t$(Get-AzProfileIdentity -Name $_)" } | + fzf --prompt 'az profile> ' --delimiter "`t" --with-nth 1 --preview 'echo {2..}' + + if ([string]::IsNullOrWhiteSpace($picked)) { return '' } + return ($picked -split "`t")[0] } -function Switch-AzWork { +function Switch-AzProfile { <# .SYNOPSIS - Switch the Azure CLI back to the WORK account by unsetting AZURE_CONFIG_DIR, so az - falls back to the default ~/.azure that already holds the work login. + Switch the Azure CLI to a named profile by pointing AZURE_CONFIG_DIR at + ~/.azure-profiles/, so each profile's token cache holds exactly one identity. .DESCRIPTION Persists the choice to the ~/.azure-active-profile state file so a new shell restores it, clears the session-cached ADO bearer token (so prr re-authenticates against this account), then announces the switch and — best effort — the active - account. Aliased as azw. + account. Aliased as azs. #> - Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore - Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'work' + param([string]$Name) + + # Only an OMITTED -Name opens the picker: `azs ''` is a bug in the caller, so it must + # fall through to validation and throw rather than turning interactive. + if (-not $PSBoundParameters.ContainsKey('Name')) { + $Name = Select-AzProfileName + # Cancelled pick — leave every bit of state alone. + if (-not $Name) { return } + } + + if (-not (Test-AzProfileName -Name $Name)) { + throw "Invalid az profile name '$Name'. Names must match '^[A-Za-z0-9][A-Za-z0-9_-]*$' — letters, digits, underscore and hyphen only, starting with a letter or digit." + } + + [string]$announce = '' + if ($Name -eq 'default') { + # Reserved: `default` IS az's own ~/.azure, so unset rather than point at a + # ~/.azure-profiles/default dir — and create nothing. + Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore + $announce = '→ az profile → default (az''s own ~/.azure)' + } else { + [string]$dir = Join-Path $HOME '.azure-profiles' $Name + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Write-Host " created $dir — run: az login" -ForegroundColor DarkGray + } + $env:AZURE_CONFIG_DIR = $dir + $announce = "→ az profile → $Name ($dir)" + } + + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value $Name # Force prr/Invoke-AdoPrReview to re-fetch a token for the now-active account # instead of listing the previous account's PRs from the stale cached token. $global:__AdoAccessToken = $null - Write-Host '→ az account → work (default ~/.azure)' -ForegroundColor Cyan + Write-Host $announce -ForegroundColor Magenta Show-AzAccountStatus } function Restore-AzActiveProfile { <# .SYNOPSIS - Apply the last-used az account by reading the ~/.azure-active-profile state file + Apply the last-used az profile by reading the ~/.azure-active-profile state file and setting AZURE_CONFIG_DIR accordingly. Silent by design — the profile calls it in Phase 1 and startup must stay quiet. Not aliased; called once at profile load. #> - $stateFile = Join-Path $HOME '.azure-active-profile' - $state = if (Test-Path -LiteralPath $stateFile) { - (Get-Content -LiteralPath $stateFile -ErrorAction Ignore | Select-Object -First 1) + # Unconditional and first, so it holds on every code path: AZURE_EXTENSION_DIR + # decouples the extension store from AZURE_CONFIG_DIR. Without it a non-default + # profile sees ZERO extensions and az devops / az graph break. One shared store + # for every profile, never per-profile. One assignment, no read. + $env:AZURE_EXTENSION_DIR = Join-Path $HOME '.azure' 'cliextensions' + + # Phase 1 cost budget: exactly ONE state-file read, no enumeration, no JSON. + [string]$stateFile = Join-Path $HOME '.azure-active-profile' + [string]$state = '' + if (Test-Path -LiteralPath $stateFile) { + $state = [string](Get-Content -LiteralPath $stateFile -ErrorAction Ignore | Select-Object -First 1) } - if ($state) { $state = $state.Trim() } - if ($state -eq 'personal') { - $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + $state = $state.Trim() + if ($state -ne 'default' -and (Test-AzProfileName -Name $state)) { + # No existence check — a missing dir just means az sees an empty config until + # the user switches or logs in, and a second FS touch buys nothing. + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles' $state } else { - # work, missing, empty, or unrecognized — ACTIVELY unset, never merely skip, so an - # inherited/parent AZURE_CONFIG_DIR can't survive against a non-personal state. + # default, missing, empty, or invalid — ACTIVELY unset, never merely skip, so an + # inherited/parent AZURE_CONFIG_DIR can't survive. Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore } + + # Restore changes the active account exactly as Switch-AzProfile does, so it owes the + # same clear. A fresh shell has nothing cached, but `. $PROFILE` in a session that has + # already run prr does — and if another shell rewrote the state file meanwhile, prr + # would otherwise list the PREVIOUS account's PRs from the stale session token. + $global:__AdoAccessToken = $null } -Set-Alias -Name azp -Value Switch-AzPersonal -Set-Alias -Name azw -Value Switch-AzWork +Set-Alias -Name azs -Value Switch-AzProfile diff --git a/powershell/Profile/Set-Prompt.ps1 b/powershell/Profile/Set-Prompt.ps1 index 3c5c391..3671e7c 100644 --- a/powershell/Profile/Set-Prompt.ps1 +++ b/powershell/Profile/Set-Prompt.ps1 @@ -522,17 +522,20 @@ function Get-ShortenedBranch { function Get-AzCliAccountSegment { <# .SYNOPSIS - Render the active az CLI account tag (az:work / az:personal) for the prompt - context line, so the current account is always visible. Cheap by design — - reads $env:AZURE_CONFIG_DIR only, never spawns az. Set (→ ~/.azure-personal) - means personal; unset (the default ~/.azure) means work — the asymmetric - contract owned by Profile/AzCliAccount.ps1 (Switch-Az*). + Render the active az CLI account tag (az: / az:default) for the + prompt context line, so the current profile is always visible. Cheap by + design — reads $env:AZURE_CONFIG_DIR only, never spawns az. Set means the + named profile at that path (name = the path leaf, e.g. az:work for + ~/.azure-profiles/work); unset means az's own default ~/.azure, rendered + as az:default — the named-profile contract owned by + Profile/AzCliAccount.ps1 (Switch-AzProfile). #> $c = $global:PromptConst if ($env:AZURE_CONFIG_DIR) { - return "$($c.DimWhite)az:$($c.Magenta)personal$($c.Reset) " + $name = Split-Path -Leaf $env:AZURE_CONFIG_DIR + return "$($c.DimWhite)az:$($c.Magenta)$name$($c.Reset) " } - return "$($c.DimWhite)az:$($c.Green)work$($c.Reset) " + return "$($c.DimWhite)az:$($c.Green)default$($c.Reset) " } # --- Prompt function -------------------------------------------------------- @@ -681,9 +684,10 @@ function prompt { $null = $contextLine.Append("$($c.Cyan)$($az.Subscription)$($c.Reset) ") } - # az CLI active account — always shown so the current account (work/personal) - # is never in doubt. Cheap: an env-var read, no az process. This makes the - # context line render on every prompt (there is now always something to show). + # az CLI active profile — always shown so the current profile (az:, or + # az:default when unset) is never in doubt. Cheap: an env-var read, no az + # process. This makes the context line render on every prompt (there is now + # always something to show). $null = $contextLine.Append((Get-AzCliAccountSegment)) # Background jobs count diff --git a/powershell/README.md b/powershell/README.md index f06c42e..3eb1b0c 100644 --- a/powershell/README.md +++ b/powershell/README.md @@ -22,7 +22,7 @@ PowerShell 7 profile and prompt for Windows (and Linux where applicable). |---|---| | `Microsoft.PowerShell_profile.ps1` | Shared profile — used on all machines | | `Profile/Set-Prompt.ps1` | Prompt definition (jj/git + Azure context) | -| `Profile/AzCliAccount.ps1` | Azure CLI account switch (`azw`/`azp`) — defines functions only; the profile calls `Restore-AzActiveProfile` in Phase 1 | +| `Profile/AzCliAccount.ps1` | Azure CLI named-profile switch (`azs`) — defines functions only; the profile calls `Restore-AzActiveProfile` in Phase 1 | The installer generates a stub at `~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1` that dot-sources the repo file — changes are live immediately without re-running setup. @@ -101,41 +101,80 @@ in `Set-Prompt.ps1`. - jj (Jujutsu) change-id, closest bookmark, ahead count, and state — shown instead of git in jj repos (takes precedence in colocated repos; toggle `ShowJj`). Gate is a filesystem walk; up to 3 jj processes, all `--ignore-working-copy`. Renders `jj: * ∅ ✎` (`∅` empty, `✎` no description, `!` conflict) - Git branch and status — synchronous, 3 git processes per prompt (used when not in a jj repo) - Azure subscription context — async via background runspace, refreshed every 60s -- Active **az CLI account** tag (`az:work` / `az:personal`) — always shown so the current account is never in doubt; a cheap `$env:AZURE_CONFIG_DIR` read, no `az` process (see the account-switch section below) +- Active **az CLI profile** tag (`az:`, or `az:default` when `AZURE_CONFIG_DIR` is unset) — always shown so the current account is never in doubt; a cheap `$env:AZURE_CONFIG_DIR` read, no `az` process (see the profile-switch section below) - Last command exit status (colour coded) and execution time - Truncated path for long directories - Windows Terminal OSC 9;9 CWD tracking; also syncs the Win32 process CWD so Zellij opens new panes in the current directory -## Azure CLI account switch (`azw` / `azp`) +## Azure CLI profile switch (`azs`) -Flip the Azure CLI (`az`) between a **work** and a **personal** account in the running -shell — no new terminal, no re-login. `az` keeps all state (including the token cache) -under one config dir, so each account gets its own dir via `AZURE_CONFIG_DIR`: +Switch the Azure CLI (`az`) between **named profiles** in the running shell — no new +terminal, no re-login. `az` keeps all state (including the token cache) under one config +dir, so each profile is its own dir at `~/.azure-profiles/`, selected by pointing +`AZURE_CONFIG_DIR` at it. The function is `Switch-AzProfile`, aliased `azs`: -| Alias | Function | Account | `AZURE_CONFIG_DIR` | -|---|---|---|---| -| `azw` | `Switch-AzWork` | work | **unset** → default `~/.azure` (keeps the existing work login) | -| `azp` | `Switch-AzPersonal` | personal | `~/.azure-personal` (isolated token cache) | - -The strategy is deliberately **asymmetric**: work stays on the default `~/.azure` (so an -existing work login is untouched), and only personal gets a redirected config dir. - -The active account is **always visible in the prompt** as an `az:work` / `az:personal` tag -(green for work, magenta for personal) on the context line — driven purely by -`$env:AZURE_CONFIG_DIR`, so it costs nothing and never spawns `az`. +```powershell +azs # fzf picker: every profile plus default, with its cached identity +azs work # switch to ~/.azure-profiles/work +azs personal # switch to ~/.azure-profiles/personal +azs default # unset AZURE_CONFIG_DIR — back to az's own ~/.azure +azs work-admin # dir doesn't exist? it's created, and you're told to run: az login +``` -- **Persistence**: each switch writes `work` or `personal` to the `~/.azure-active-profile` - state file. On startup the profile calls `Restore-AzActiveProfile` (Phase 1) which reads - that file and re-applies the choice — so the active account survives new shells. A `work`, - missing, empty, or unrecognized state **actively unsets** `AZURE_CONFIG_DIR` (it never - merely skips), so an inherited value can't leak a stale personal dir into a work shell. -- **`prr` follows the active account**: switching clears the session-cached ADO bearer token +| Command | `AZURE_CONFIG_DIR` | Prompt tag | +|---|---|---| +| `azs ` | `~/.azure-profiles/` | `az:` | +| `azs default` | **unset** → az's own `~/.azure` | `az:default` | + +Names must match `^[A-Za-z0-9][A-Za-z0-9_-]*$` — that is also the path-injection guard, +since the name becomes a directory. `default` is reserved and never gets a directory. + +**One identity per profile dir** is the point, not a side effect. `az devops` builds its +auth candidate list from the config dir's cached subscriptions and returns the first +identity that can list any project — so a dir holding two logins can silently act as an +account you did not pick. Never log two accounts into one profile dir. + +There is **no manifest or config file** listing profiles: the directories under +`~/.azure-profiles/` are the only registry. Adding a profile is "switch to it and log in", +never "edit a file". + +The active profile is **always visible in the prompt** as an `az:` tag (magenta), or +`az:default` (green) when `AZURE_CONFIG_DIR` is unset — driven purely by that env var, so +it costs nothing and never spawns `az`. + +- **Persistence**: each switch writes the name to the `~/.azure-active-profile` state file. + On startup the profile calls `Restore-AzActiveProfile` (Phase 1), whose budget is exactly + **one state-file read** — no directory enumeration, no JSON parsing (all discovery lives + in the picker). A `default`, missing, empty, or invalid state **actively unsets** + `AZURE_CONFIG_DIR` (it never merely skips), so an inherited value can't leak in. +- **Extensions are shared, not per-profile**: `Restore-AzActiveProfile` sets + `AZURE_EXTENSION_DIR` unconditionally to `~/.azure/cliextensions`. Without it a + non-default profile sees **zero** extensions and `az devops` / `az graph` stop existing. +- **The picker never runs `az`**: bare `azs` lists the profiles through `fzf`, previewing + each one's identity read straight out of its `azureProfile.json`. Works while logged out; + a profile with no login shows the `az login` hint. +- **`prr` follows the active profile**: switching clears the session-cached ADO bearer token (`$global:__AdoAccessToken`), so `prr` / `Invoke-AdoPrReview` (Phase 2b) re-authenticates - against whichever account is now active. Run `prr` under the account that owns the PRs. -- Each switch announces the target account immediately, then makes a best-effort + against whichever account is now active. Run `prr` under the profile that owns the PRs. +- Each switch announces the target profile immediately, then makes a best-effort `az account show` to append the active user (or an `az login` hint). The announce never blocks or throws if `az` is slow, absent, or logged out; it never runs `az login` for you. +### Migration + +1. Create `~/.azure-profiles` and move the old dir into it: + `~/.azure-personal` → `~/.azure-profiles/personal`. +2. For every other account, run `azs ` (which creates an empty dir) and then + `az login` once inside it. **Seed the dirs empty** — copying an existing config dir + would cache two identities in one profile, which is exactly what this design exists to + prevent. +3. Leave `~/.azure` as-is: on Windows it is a single case-insensitive directory shared + with **Azure PowerShell** (its `AzureRmContext.json` sits beside az's + `azureProfile.json`), so it is never swapped, junctioned, or moved. Just stop logging + the CLI into it. +4. The existing `~/.azure-active-profile` value maps onto `~/.azure-profiles/`. + Run `azs` once after migrating so the state file lands on a profile that exists. + ## Per-machine differences There is a single shared profile — no per-machine variants. Machine-specific diff --git a/tests/AzCliAccount.Tests.ps1 b/tests/AzCliAccount.Tests.ps1 index 1e09b73..ee0ea03 100644 --- a/tests/AzCliAccount.Tests.ps1 +++ b/tests/AzCliAccount.Tests.ps1 @@ -1,10 +1,11 @@ #Requires -Version 7 -# Pester tests for powershell/Profile/AzCliAccount.ps1 — the az CLI account switch -# (azw/azp). The file defines functions with no load-time side effects, so it is -# dot-sourced directly (unlike the profile, which is AST-lifted). `az` is shadowed by -# a function stub so the best-effort `az account show` announce never hits the network -# or requires az to be installed. Assertions are on STATE — $env:AZURE_CONFIG_DIR, the -# state-file content, and the cleared $global:__AdoAccessToken — never on az invocation. +# Pester tests for powershell/Profile/AzCliAccount.ps1 — the named az profile switcher +# (azs). The file defines functions with no load-time side effects, so it is dot-sourced +# directly (unlike the profile, which is AST-lifted). `az` is shadowed by a function stub +# so the best-effort `az account show` announce never hits the network or requires az to +# be installed. Assertions are on STATE — $env:AZURE_CONFIG_DIR, $env:AZURE_EXTENSION_DIR, +# the state-file content, and the cleared $global:__AdoAccessToken — never on az +# invocation. All identities in fixtures are fake: this is a public repo. BeforeAll { $scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell' 'Profile' 'AzCliAccount.ps1' @@ -18,7 +19,7 @@ BeforeAll { Describe 'AzCliAccount' { BeforeEach { # Redirect $HOME to a fresh, isolated dir so path resolution (state file + - # personal config dir) is scoped per test and no state file leaks across tests. + # profile dirs) is scoped per test and no state file leaks across tests. # $HOME is a ReadOnly, AllScope automatic variable — AllScope defeats a child-scope # shadow, so the only way to redirect it is Set-Variable -Force (which overrides # ReadOnly), saved and restored in AfterEach. Set-Variable (not `$HOME = …`) also @@ -30,6 +31,7 @@ Describe 'AzCliAccount' { # Save mutated global state; the functions overwrite these. $script:origConfigDir = $env:AZURE_CONFIG_DIR + $script:origExtensionDir = $env:AZURE_EXTENSION_DIR $script:origAdoToken = $global:__AdoAccessToken } @@ -40,63 +42,230 @@ Describe 'AzCliAccount' { } else { $env:AZURE_CONFIG_DIR = $script:origConfigDir } + if ($null -eq $script:origExtensionDir) { + Remove-Item Env:AZURE_EXTENSION_DIR -ErrorAction Ignore + } else { + $env:AZURE_EXTENSION_DIR = $script:origExtensionDir + } $global:__AdoAccessToken = $script:origAdoToken } - Context 'Switch-AzPersonal' { - It 'points AZURE_CONFIG_DIR at the personal config dir' { - Switch-AzPersonal - $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-personal') + Context 'Switch-AzProfile with a profile name' { + It 'points AZURE_CONFIG_DIR at the named profile dir' { + Switch-AzProfile -Name work + $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-profiles' 'work') + } + + It 'creates the profile dir when it does not exist' { + Switch-AzProfile -Name work + Test-Path -LiteralPath (Join-Path $HOME '.azure-profiles' 'work') | Should -BeTrue } - It 'persists "personal" to the state file' { - Switch-AzPersonal + It 'persists the profile name to the state file' { + Switch-AzProfile -Name work $state = (Get-Content -LiteralPath (Join-Path $HOME '.azure-active-profile')).Trim() - $state | Should -Be 'personal' + $state | Should -Be 'work' } It 'clears the cached ADO access token so prr re-auths against the new account' { $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } - Switch-AzPersonal + Switch-AzProfile -Name work $global:__AdoAccessToken | Should -BeNullOrEmpty } } - Context 'Switch-AzWork' { - It 'unsets AZURE_CONFIG_DIR, persists "work", and clears the cached token' { + Context 'Switch-AzProfile -Name default' { + It 'unsets AZURE_CONFIG_DIR, persists "default", and clears the cached token' { # Pre-set both so the unset and the clear are observable transitions. - $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles' 'work' $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } - Switch-AzWork + Switch-AzProfile -Name default $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty - (Get-Content -LiteralPath (Join-Path $HOME '.azure-active-profile')).Trim() | Should -Be 'work' + (Get-Content -LiteralPath (Join-Path $HOME '.azure-active-profile')).Trim() | Should -Be 'default' $global:__AdoAccessToken | Should -BeNullOrEmpty } + + It 'creates no profile dir for the reserved name' { + # `default` IS az's own ~/.azure — a ~/.azure-profiles/default dir would be a + # second, never-used config dir that the picker would then offer. + Switch-AzProfile -Name default + Test-Path -LiteralPath (Join-Path $HOME '.azure-profiles' 'default') | Should -BeFalse + } + } + + Context 'Switch-AzProfile with an invalid name' { + # A name becomes a directory under ~/.azure-profiles, so a separator or a + # traversal segment must be rejected before anything is written or set. + It 'throws and mutates nothing for <_>' -ForEach @('bad/../name', 'has space', '..\evil', '-leading') { + $preset = Join-Path $HOME '.azure-profiles' 'work' + $env:AZURE_CONFIG_DIR = $preset + $stateFile = Join-Path $HOME '.azure-active-profile' + Set-Content -LiteralPath $stateFile -Value 'work' + + { Switch-AzProfile -Name $_ } | Should -Throw + + $env:AZURE_CONFIG_DIR | Should -Be $preset + (Get-Content -LiteralPath $stateFile).Trim() | Should -Be 'work' + } } Context 'Restore-AzActiveProfile' { - It 'sets AZURE_CONFIG_DIR to the personal dir when the state says "personal"' { - Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'personal' + It 'sets AZURE_CONFIG_DIR to the named profile dir even when that dir does not exist' { + # Phase 1 does no existence check — a missing dir just means az sees an empty + # config until the user logs in, and a second FS touch buys nothing. + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'work' Restore-AzActiveProfile - $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-personal') + $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-profiles' 'work') + Test-Path -LiteralPath $env:AZURE_CONFIG_DIR | Should -BeFalse } - It 'actively unsets a pre-set AZURE_CONFIG_DIR when the state says "work"' { - # An inherited AZURE_CONFIG_DIR must not survive a "work" state — restore must - # unset it, not merely skip. - $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' - Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'work' + It 'actively unsets a pre-set AZURE_CONFIG_DIR when the state is ' -ForEach @( + @{ Name = 'default'; Content = 'default' } + @{ Name = 'empty'; Content = '' } + @{ Name = 'an invalid token'; Content = '..\evil' } + ) { + # An inherited AZURE_CONFIG_DIR from a parent process must not survive — + # restore must unset it, not merely skip. + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles' 'work' + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value $Content Restore-AzActiveProfile - $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty + # Test-Path, not -BeNullOrEmpty: the latter also passes when the variable + # still exists holding an empty string, which is a different state from + # removed and not the one this contract promises. + Test-Path Env:AZURE_CONFIG_DIR | Should -BeFalse } It 'unsets a pre-set AZURE_CONFIG_DIR when the state file is missing' { # Fresh $HOME has no state file; an inherited AZURE_CONFIG_DIR must not survive. - $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles' 'work' Restore-AzActiveProfile - $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty + Test-Path Env:AZURE_CONFIG_DIR | Should -BeFalse + } + + It 'clears the cached ADO access token when the state is ' -ForEach @( + @{ Name = 'a named profile'; Content = 'work' } + @{ Name = 'default'; Content = 'default' } + @{ Name = 'an invalid token'; Content = '..\evil' } + ) { + # Restore switches accounts just as Switch-AzProfile does, so it owes the same + # token clear. A fresh shell has no cached token, but `. $PROFILE` in a session + # that already ran prr does — and after another shell rewrote the state file it + # would otherwise list the PREVIOUS account's PRs from the stale cached token. + $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value $Content + Restore-AzActiveProfile + $global:__AdoAccessToken | Should -BeNullOrEmpty + } + + It 'points AZURE_EXTENSION_DIR at the shared extension store when the state is ' -ForEach @( + @{ Name = 'a named profile'; Content = 'work' } + @{ Name = 'default'; Content = 'default' } + ) { + # Without this a non-default profile sees ZERO extensions and az devops / + # az graph break. One shared store for every profile, set on every path. + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value $Content + Restore-AzActiveProfile + $env:AZURE_EXTENSION_DIR | Should -Be (Join-Path $HOME '.azure' 'cliextensions') + } + } + + Context 'Get-AzProfileIdentity' { + # Feeds the fzf picker's preview column. It reads azureProfile.json as a plain + # file — never an `az` process — so the preview costs nothing per keystroke. + # Every identity below is fake: this is a public repo. + It 'reports the cached account of a named profile' { + $dir = Join-Path $HOME '.azure-profiles' 'work' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + @{ + subscriptions = @( + @{ name = 'Example Subscription'; isDefault = $true; user = @{ name = 'user@example.com' } } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $dir 'azureProfile.json') + + Get-AzProfileIdentity -Name 'work' | Should -BeLike '*user@example.com*' + } + + It 'reports the cached account of the default profile from az''s own ~/.azure' { + $dir = Join-Path $HOME '.azure' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + @{ + subscriptions = @( + @{ name = 'Example Subscription'; isDefault = $true; user = @{ name = 'other@example.com' } } + ) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $dir 'azureProfile.json') + + Get-AzProfileIdentity -Name 'default' | Should -BeLike '*other@example.com*' + } + + It 'falls back to subscription names when no entry carries a user (service principal logins)' { + $dir = Join-Path $HOME '.azure-profiles' 'work' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + @{ + subscriptions = @(@{ name = 'Example Subscription'; isDefault = $true }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $dir 'azureProfile.json') + + # Logged in, just not with a user identity — must not read as "not logged in". + Get-AzProfileIdentity -Name 'work' | Should -BeLike '*Example Subscription*' + } + + It 'reports the login hint when the profile has no azureProfile.json' { + Get-AzProfileIdentity -Name 'work' | Should -Be 'not logged in — run: az login' + } + + It 'reports the login hint when azureProfile.json is unparsable' { + $dir = Join-Path $HOME '.azure-profiles' 'work' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $dir 'azureProfile.json') -Value 'not json {' + + Get-AzProfileIdentity -Name 'work' | Should -Be 'not logged in — run: az login' + } + } + + Context 'Switch-AzProfile with no name' { + # The picker itself is interactive (bare fzf), so it is mocked at that boundary; + # what is asserted is the resulting state, not that the picker was called. + It 'switches to the picked profile' { + Mock Select-AzProfileName { 'personal' } + Switch-AzProfile + $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-profiles' 'personal') + } + + It 'throws rather than opening the picker when -Name is supplied but empty' { + # `azs ''` from a script or a mistyped expansion is a bug, not a request for + # the interactive picker — only an omitted -Name opens fzf. + Mock Select-AzProfileName { 'personal' } + { Switch-AzProfile -Name '' } | Should -Throw + } + + It 'changes nothing when the pick is cancelled' { + Mock Select-AzProfileName { '' } + $preset = Join-Path $HOME '.azure-profiles' 'work' + $env:AZURE_CONFIG_DIR = $preset + $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } + + Switch-AzProfile + + $env:AZURE_CONFIG_DIR | Should -Be $preset + $global:__AdoAccessToken | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath (Join-Path $HOME '.azure-active-profile') | Should -BeFalse + } + } + + Context 'Aliases' { + It 'exposes azs for Switch-AzProfile' { + (Get-Alias azs).ResolvedCommandName | Should -Be 'Switch-AzProfile' + } + + It 'no longer defines the two-account switcher <_>' -ForEach @( + 'Switch-AzWork', 'Switch-AzPersonal', 'azw', 'azp' + ) { + # Asserted against the FILE, not the session: an interactive shell has the + # installed profile's old aliases loaded, which would flake a Get-Alias check + # while CI (-NoProfile) passed. + $source = Get-Content -LiteralPath $scriptPath -Raw + $source | Should -Not -Match "\b$_\b" } } } diff --git a/tests/Set-Prompt.Tests.ps1 b/tests/Set-Prompt.Tests.ps1 index 0a9d9bd..4318bf7 100644 --- a/tests/Set-Prompt.Tests.ps1 +++ b/tests/Set-Prompt.Tests.ps1 @@ -271,14 +271,24 @@ Describe 'Get-AzCliAccountSegment — az CLI account tag' { else { $env:AZURE_CONFIG_DIR = $script:origAzDir } } - It 'renders az:personal when AZURE_CONFIG_DIR is set (personal account active)' { - $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + It 'renders az:work for a work profile dir' { + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles/work' + Get-AzCliAccountSegment | Should -Match 'az:.*work' + } + + It 'renders az:personal for a personal profile dir' { + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles/personal' Get-AzCliAccountSegment | Should -Match 'az:.*personal' } - It 'renders az:work when AZURE_CONFIG_DIR is unset (default work account)' { + It 'renders az:work-admin for an arbitrary profile name' { + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-profiles/work-admin' + Get-AzCliAccountSegment | Should -Match 'az:.*work-admin' + } + + It 'renders az:default when AZURE_CONFIG_DIR is unset' { Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore - Get-AzCliAccountSegment | Should -Match 'az:.*work' + Get-AzCliAccountSegment | Should -Match 'az:.*default' } }