Skip to content

az CLI profile switcher: named profiles replacing azw/azp (tracking) #37

Description

@jinyeow

Tracking issue for the az CLI profile switcher rework. Design context, rejected alternatives, and the shared contract binding every child ticket.

Children: #33 (switcher), #34 (prompt segment), #35 (repo docs), #36 (wiki).

Problem

powershell/Profile/AzCliAccount.ps1 switches the Azure CLI between exactly two accounts asymmetrically: azp sets AZURE_CONFIG_DIR=~/.azure-personal, azw unsets it so az falls back to the default ~/.azure.

Two failures:

  1. Not portable. "Work" isn't a profile — it's "whatever happens to be logged into the default dir on this machine." On a second machine that's a guess, and the prompt confidently renders az:work over whatever is actually there.
  2. Can't express more than two profiles, one of which is defined by absence.

Additional motivation found while investigating: az devops builds its auth candidate list from the config dir's cached subscriptions and returns the first identity that can list any project. A config dir holding several identities can therefore silently authenticate as an account you did not select, with no signal at the shell. One identity per profile dir is the mitigation this design enforces.

Design

Named profiles, each a self-contained AZURE_CONFIG_DIR under ~/.azure-profiles/<name>, switched by Switch-AzProfile (alias azs) with an fzf picker. The prompt renders the profile name; an unset AZURE_CONFIG_DIR renders az:default.

Rejected alternatives — do not reintroduce

  • Junction or symlink ~/.azure at the active profile. Mechanically the best "everything follows the active account" option — retargeting a reparse point is one metadata write and succeeds even with open file handles (verified). Rejected because on Windows the default dir is ONE case-insensitive directory (C:\Users\<user>\.Azure) shared by Azure CLI and Azure PowerShell — AzureRmContext.json and AzureRmContextSettings.json sit beside azureProfile.json. Junctioning it would silently switch every Get-Az* call too. Azure PowerShell is out of scope; ~/.azure stays untouched.
  • Directory swapping (profiles parked in a store, swapped into ~/.azure). Move-Item on a directory is copy-then-delete, not atomic, and leaves torn state on failure (verified: a failed swap left an empty destination while the source still held the payload). True atomic rename via [IO.Directory]::Move fails outright when any file inside is open, and az holds msal_token_cache.bin open during every command. Structurally it also makes the active account machine-global mutable state — switching in one shell silently changes the account under every other shell.
  • Discovery by a ~/.azure-* glob. The home directory already contains .azure-devops, .azure-functions-core-tools, and .azurefunctions, which such a glob would offer as az profiles. Hence a container directory.
  • A manifest/config file (json/yaml/toml) listing profiles. A second source of truth that drifts from the directories actually holding the credentials. Adding a profile must be "switch to it and log in", never "edit a file". Do not add one "for robustness".

Shared contract — binds every child ticket

  • Container ~/.azure-profiles; profile dir ~/.azure-profiles/<name>.
  • The profile name is exactly the leaf of $env:AZURE_CONFIG_DIR. Unset = the reserved name default = az's own ~/.azure.
  • Valid names match ^[A-Za-z0-9][A-Za-z0-9_-]*$. default is reserved: switching to it unsets AZURE_CONFIG_DIR and creates no directory.
  • State file ~/.azure-active-profile, first line = profile name.
  • No component ever runs az to learn the active profile — the prompt reads the env var, the picker reads azureProfile.json files.
  • AZURE_EXTENSION_DIR is set once, unconditionally, at a single shared extension store. Verified: with a fresh AZURE_CONFIG_DIR and no override az extension list is empty; with the override pointed at the existing cliextensions dir it lists the installed extensions. Without it, every non-default profile loses az devops and az graph.
  • Restore-AzActiveProfile runs in profile Phase 1 — one state-file read, zero enumeration, zero JSON parsing. Discovery lives only in the picker.
  • Every switch clears $global:__AdoAccessToken so prr re-authenticates against the now-active account.
  • TDD throughout: Pester cases written first and seen RED, then implemented to GREEN. Existing tests are updated to pin new behaviour, never deleted to pass.
  • The repo is public: no real account names, tenant GUIDs, employer names, or organization URLs in code, tests, fixtures, comments, docs, or commits.

Migration

  1. Create ~/.azure-profiles and move ~/.azure-personal~/.azure-profiles/personal.
  2. For each other account: azs <name> (creates an empty dir), then az login once inside it. Seed empty so no profile dir ever caches two identities — that is the entire point.
  3. Leave ~/.azure as-is; the Azure PowerShell context lives there. Stop logging the CLI into it.
  4. Run azs once after migrating so the state file maps onto the new container.

Verification

Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1

$config = New-PesterConfiguration
$config.Run.Path = 'tests'
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $config

Manual smoke: a new shell shows az:default or the restored profile name; azs opens the picker with identity preview; azs <newname> creates the dir and prints the login hint; az extension list under a non-default profile still shows the shared extensions.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions