Skip to content

feat(powershell): named az CLI profile switcher (azs) replacing azw/azp - #39

Merged
jinyeow merged 6 commits into
mainfrom
feat/az-profile-switcher
Jul 27, 2026
Merged

feat(powershell): named az CLI profile switcher (azs) replacing azw/azp#39
jinyeow merged 6 commits into
mainfrom
feat/az-profile-switcher

Conversation

@jinyeow

@jinyeow jinyeow commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Replaces the asymmetric two-account az CLI switcher with named profiles.

Closes #33, closes #34, closes #35, closes #36. Design context and rejected alternatives: #37.

Why

azp set AZURE_CONFIG_DIR=~/.azure-personal; azw unset it so az fell back to the default ~/.azure. So "work" was not a profile — it was "whatever happens to be logged into the default dir on this machine". That didn't survive a second machine, the prompt confidently rendered az:work over whatever was actually there, and a third account couldn't be expressed at all.

A second reason emerged while investigating: az devops builds its auth candidate list from the config dir's cached subscriptions and uses the first identity that can list any project. A config dir holding several logins can therefore authenticate as an account you did not select, with no signal at the shell. The live default dir here held three identities. One identity per profile dir is what this design enforces.

What changed

  • Switch-AzProfile (alias azs) points AZURE_CONFIG_DIR at ~/.azure-profiles/<name>. -Name switches directly; omitting it opens an fzf picker.
  • The picker previews each profile's cached identity by reading its azureProfile.json directly — no az process per keystroke, and it works while logged out. Bare fzf in a native pipeline, not PSFzf's Invoke-Fzf (which desyncs under psmux's ConPTY).
  • default is reserved: it means unset AZURE_CONFIG_DIR, i.e. az's own ~/.azure, and creates no directory.
  • Restore-AzActiveProfile stays inside its Phase 1 budget of one state-file read — all enumeration lives in the picker.
  • AZURE_EXTENSION_DIR is now set unconditionally at one shared store. Without it a fresh config dir has zero extensions and az devops / az graph break outright.
  • The prompt renders az:<name>, or az:default when unset. The old code rendered az:work on unset, which was the specific lie that made the design unportable.
  • Docs rewritten in place in CLAUDE.md and powershell/README.md, including the four rejected alternatives with their deciding facts, plus a migration section. Wiki updated separately.

Notable decisions

~/.azure is deliberately left untouched. On Windows it is one case-insensitive directory shared with Azure PowerShellAzureRmContext.json sits beside azureProfile.json — so junctioning or swapping it would silently switch every Get-Az* too. Azure PowerShell is out of scope here.

There is no manifest file listing profiles. The directories are the only registry; adding a profile is "switch to it and log in", never "edit a file".

Verification

  • tests/AzCliAccount.Tests.ps1 + tests/Set-Prompt.Tests.ps1: 50 passed, 0 failed. Written test-first throughout.
  • PSScriptAnalyzer clean against the repo root settings file (remaining findings are pre-existing, in vendored psmux/plugins/, which the CI gate excludes).
  • The fzf --delimiter / --with-nth contract was smoke-tested directly rather than assumed: --with-nth 1 still returns the whole line, so splitting on the tab to recover the name is correct.

Review

Codex reviewed the branch. Applied from it: Restore-AzActiveProfile now clears $global:__AdoAccessToken (it changes accounts just as a switch does, so . $PROFILE in a session that had run prr kept a stale token); unset assertions now use Test-Path Env: rather than Should -BeNullOrEmpty, which also passes for a variable that exists holding an empty string; two stale azw/azp comments in files no ticket owned.

Deferred deliberately: rejecting reparse points to guarantee filesystem confinement. The name regex guards against malformed names and string traversal, which it does completely. Planting a junction inside ~/.azure-profiles/ requires write access to the home directory, and anyone with that can already read the token cache or edit the profile script — so the check would defend against an attacker who has already won by easier routes, at the cost of extra filesystem calls on every switch and breaking legitimate uses like relocating a profile dir to another drive.

Migration

Not automatic — see the Migration section in powershell/README.md. In short: move ~/.azure-personal into the new container, then azs <name> + az login once per account, seeding each dir empty so none ever caches two identities.

jinyeow added 4 commits July 27, 2026 11:10
Get-AzCliAccountSegment hardcoded az:personal when AZURE_CONFIG_DIR was set
and az:work when unset. Under the named-profile scheme an unset variable
means az's own default ~/.azure, whatever is logged into it on that machine
— rendering it as "work" was a lie and the reason the old design was
unportable.

Render the path leaf as the profile name so arbitrary names work, and
az:default when unset. Still env-read plus string ops only: no az process
and no filesystem access, since this runs on every prompt.

Closes #34
The old switch was asymmetric: azp set AZURE_CONFIG_DIR to ~/.azure-personal
while azw UNSET it so az fell back to the default ~/.azure. That made "work"
mean "whatever is logged into the default dir on this machine" rather than a
profile, so it did not survive a second machine, and it could not express a
third account.

Profiles are now named directories under ~/.azure-profiles/<name>, each a
self-contained AZURE_CONFIG_DIR holding exactly one identity. That last part
is load-bearing: az devops builds its auth candidate list from the config
dir's cached subscriptions and uses the first identity that can list any
project, so a dir holding several accounts can silently authenticate as one
you did not pick.

Switch-AzProfile (azs) takes -Name, or opens an fzf picker when omitted.
The picker previews each profile's cached identity by reading its
azureProfile.json directly, so no az process is spawned per keystroke, and
it is the only path that enumerates. Restore-AzActiveProfile stays within
its Phase 1 budget of one state-file read.

AZURE_EXTENSION_DIR is now set unconditionally at a single shared extension
store. Without it a non-default config dir sees zero extensions and both
az devops and az graph break.

Closes #33
Both files documented the old two-account asymmetry as a deliberate design
decision, which made them actively misleading now that the design is gone.
Rewritten in place rather than annotated.

CLAUDE.md gains the four rejected alternatives with their deciding facts —
the shared ~/.azure directory that rules out a junction, the non-atomic
directory move, the ~/.azure-* glob collision, and the manifest as a second
source of truth — so the design cannot be re-litigated from first principles
later. powershell/README.md gains a worked example and a migration section
covering why profile dirs are seeded empty rather than copied.

Closes #35
Restore-AzActiveProfile changes the active account exactly as Switch-AzProfile
does, but did not clear $global:__AdoAccessToken. A fresh shell has nothing
cached so the gap was invisible there, but `. $PROFILE` in a session that had
already run prr kept the previous account's bearer token — and after another
shell rewrote the state file, prr would list the wrong account's PRs with no
signal.

Also strengthen the unset assertions: Should -BeNullOrEmpty passes when the
variable still exists holding an empty string, which is a different state from
removed and not what the contract promises. Test-Path Env: checks removal.

Two comments still described the retired azw/azp scheme; neither file was owned
by the implementation tickets, so they were missed.

Found by a Codex review of the branch.
Comment thread powershell/Profile/AzCliAccount.ps1 Outdated
# 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-Object { $_ } | Select-Object -Unique)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use $subscriptions.user.name.Where(...) ...?

jinyeow and others added 2 commits July 27, 2026 21:31
The PowerShell pipeline carries ~80us of fixed setup cost per stage,
independent of collection size, so the method form wins most at the small
collection sizes this code actually sees. Measured per call, N=3:

  | Where-Object { $_ }                108us -> 10us   (10x)
  @(| Where-Object { $_.isDefault })[0] 75us ->  3us   (25x)

The first-match case gains more because .Where(..., 'First') short-circuits
where @(...)[0] always enumerates the whole collection.

.Where(..., 'First') yields an EMPTY collection rather than $null when
nothing matches. The existing truthiness test handles that, but indexing
[0] into it throws under StrictMode, so the bare form is used and the
constraint is noted in a comment.
@jinyeow
jinyeow merged commit 287c17f into main Jul 27, 2026
5 checks passed
@jinyeow
jinyeow deleted the feat/az-profile-switcher branch July 27, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant