# # Minions Installer (Windows) # # Download this file from the Minions page, then run it from any directory: # powershell -ExecutionPolicy Bypass -File "$env:USERPROFILE\Downloads\install-minions.ps1" # # Installs the newest published version by default. To install one specific # known-good version instead, pin it -- the flag wins over the environment: # powershell -ExecutionPolicy Bypass -File "$env:USERPROFILE\Downloads\install-minions.ps1" -Version 1.2.3 # $env:MINIONS_INSTALL_VERSION = '1.2.3' # # Every run prints its own launcher revision as its first line. Quote that line # in a bug report: it identifies exactly which copy of this file you ran. # # Requires: Node.js 22.5+, Git, the Azure CLI, and Azure Artifacts *read* # permission on the internal ISS npm feed named below. # # Signing in is NOT a separate step you run first. An Azure CLI session you # already have is used silently; if you have none, this installer signs you in # once itself -- but only when it is running in a terminal that can answer the # prompt. A headless run fails immediately with the command to run instead of # hanging on a prompt nobody can see. # # Nothing is cloned. No PAT is created, entered, or stored: the installer takes a # short-lived Azure DevOps token from your Azure CLI session, writes it only to a # throwaway npm config, and deletes that config before it exits. # [CmdletBinding()] param( # An exact published version to install instead of the newest one on the # feed. Overrides $env:MINIONS_INSTALL_VERSION. [string]$Version ) $ErrorActionPreference = 'Stop' # ============================================================================ # Configuration # ============================================================================ # The internal channel's identity -- package, feed registry, feed name, and the # first-party Azure DevOps application ID the feed token is issued for -- is # published by the Minions package itself and generated into the block below, so # a moved feed is one synced artifact instead of four hand-edited literals. # --- BEGIN GENERATED CHANNEL BLOCK --- # Generated from minions/internal-channel.json, the checked-in copy of the # Minions package's own bin/internal-channel.json. Do not edit by hand: # re-run `bun run minions:channel:sync` instead, which rewrites this block. # Channel artifact revision: sha256:f32f945fbcbda096ed710bb426df3baedd13b3525e95c7b986a38b12d24cc774 $PackageName = '@opg-microsoft/minions' $PackageScope = '@opg-microsoft' $FeedRegistry = 'https://pkgs.dev.azure.com/office/ISS/_packaging/ProjectFeed-ISS/npm/registry/' $FeedName = 'ProjectFeed-ISS' $AdoTokenResource = '499b84ac-1321-427f-aa17-267ca6975798' # --- END GENERATED CHANNEL BLOCK --- # npm keys auth by protocol-relative registry path, not by the full URL. Derived # from the generated registry so the two can never disagree. $FeedAuthKey = $FeedRegistry -replace '^https?:', '' # The canonical installer ships inside the package, so the migration flow needs no clone. $CanonicalInstallerPath = 'bin/install-internal-minions.js' # The only shape a version pin may take: one exact published version, optionally # with a prerelease tag. A range or dist-tag resolves to a different build later, # which is the opposite of pinning, and the value is interpolated into an # `npm view` argument where a space would start a second option. Shared verbatim # with install-minions.sh and the Constellation test suite. $VersionPinPattern = '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' # ============================================================================ # Helpers # ============================================================================ function Write-Step($msg) { Write-Host "==> " -ForegroundColor Cyan -NoNewline Write-Host $msg } function Write-Ok($msg) { Write-Host "==> " -ForegroundColor Green -NoNewline Write-Host $msg } function Write-Fail($msg) { Write-Host "error: " -ForegroundColor Red -NoNewline Write-Host $msg exit 1 } function Assert-Command($name, $hint) { if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { Write-Fail "$name was not found on PATH. $hint" } } # ============================================================================ # Azure sign-in # ============================================================================ # `az login` is not a prerequisite the operator runs first. This section is the # whole contract, and install-minions.sh implements the same one: # 1. Try the token. A working session is used silently -- no prompt, no # account switch, no re-authentication. # 2. Classify a failure. Only a genuine ABSENCE of sign-in is recoverable; a # missing CLI, an unauthorized identity, and an unrecognized error are # reported as themselves rather than laundered into "please log in". # 3. Sign in at most ONCE, and only where a prompt can actually be answered. # 4. Retry the token exactly ONCE. There is no loop. # `--allow-no-subscriptions` is required, not cosmetic: an identity that has # Azure DevOps access but no Azure *subscription* is the normal shape for this # feed, and a plain `az login` fails such an account AFTER a successful # authentication -- which reads to the operator as "sign-in is broken" when the # sign-in actually worked. Deliberately not `--scope`, `--use-device-code`, or # `az account set`: nothing here switches, narrows, or persists an identity # beyond what an ordinary `az login` already does. Shared verbatim with # install-minions.sh and the Constellation test suite. $AzLoginArguments = 'login --allow-no-subscriptions' # The exact command an operator must run when this process may not prompt. $AzLoginCommand = "az $AzLoginArguments" # Split from the documented string rather than typed twice, so the command that # runs is provably the command the guidance names. $AzLoginArgumentList = $AzLoginArguments.Split(' ') # Environment markers that mean "no human is watching this terminal". A truthy # value on any of them turns the sign-in above into a hang rather than a prompt. # `MINIONS_NONINTERACTIVE` is the explicit opt-out an embedding caller sets -- # Constellation's own install.ps1 companion phase runs this launcher as a # bounded child whose console it does not own. $NoninteractiveEnvKeys = 'MINIONS_NONINTERACTIVE CI TF_BUILD GITHUB_ACTIONS BUILD_BUILDID' # Lowercase substrings of the Azure CLI's own diagnostics that identify why a # token request failed. Substrings rather than regular expressions on purpose: # this is the one grammar .NET, GNU ERE, and BSD ERE agree on byte-for-byte, so # the two launchers cannot classify the same message differently. Order below is # load-bearing -- authorization is matched BEFORE sign-in, because several of # those messages also name `az login` as a generic hint, and following it would # send an operator who needs a permission grant through a pointless sign-in. $AzMissingMarkers = 'enoent command not found no such file or directory is not recognized as an internal or external command is not recognized as the name of a cmdlet' $AzAuthorizationMarkers = 'aadsts500011 aadsts65001 aadsts53003 does not have authorization authorizationfailed insufficient privileges forbidden' $AzSignInMarkers = 'az login not logged in no accounts found no accounts were found no account found no account was found no subscription found no subscriptions found az account set please run interactive authentication is needed reauthenticat re-authenticat expired aadsts50076 aadsts50079 aadsts50173 aadsts700082 aadsts50058 aadsts700084' function Test-MarkerMatch($text, $markers) { $haystack = "$text".ToLowerInvariant() if (-not $haystack) { return $false } foreach ($marker in ($markers -split "`n")) { $needle = $marker.Trim() if ($needle -and $haystack.Contains($needle)) { return $true } } return $false } # `unknown` deliberately does NOT fall through to a sign-in: prompting for a # login that was never the problem hides the real error behind a browser window. function Get-AzTokenClassification($detail) { if (-not "$detail".Trim()) { return 'unknown' } if (Test-MarkerMatch $detail $AzMissingMarkers) { return 'az-missing' } if (Test-MarkerMatch $detail $AzAuthorizationMarkers) { return 'authorization' } if (Test-MarkerMatch $detail $AzSignInMarkers) { return 'sign-in-required' } return 'unknown' } # FAILS CLOSED. A headless run that guesses wrong does not fail -- it hangs # forever on a prompt nobody can answer, which is strictly worse than an # actionable error. function Test-InteractiveSession { foreach ($key in ($NoninteractiveEnvKeys -split "`n")) { $name = $key.Trim() if (-not $name) { continue } $value = "$([Environment]::GetEnvironmentVariable($name))".Trim().ToLowerInvariant() if ($value -and $value -ne '0' -and $value -ne 'false') { return $false } } try { if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { return $false } } catch { # A host without a real console cannot prompt either. return $false } return [Environment]::UserInteractive } # One `az` call. `2>&1` merges the CLI's diagnostics into the pipeline as error # records, which are separated from the token below -- so the classifier, and # every message it produces, read stderr only. The token stays in a variable and # is never printed, logged, or passed as an argument. function Invoke-AzTokenAttempt { # Function-scoped: this shadows the script's `Stop` for this call only, so a # native command writing to stderr under `2>&1` cannot throw. $ErrorActionPreference = 'Continue' $records = @() $exitCode = 0 try { $records = @(& az account get-access-token --resource $AdoTokenResource --query accessToken -o tsv 2>&1) $exitCode = $LASTEXITCODE } catch { return @{ Ok = $false; Token = ''; Detail = "$($_.Exception.Message)"; Empty = $false } } $stdout = @($records | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] }) $stderr = @($records | Where-Object { $_ -is [Management.Automation.ErrorRecord] }) $token = "$($stdout | Select-Object -Last 1)".Trim() $detail = (($stderr | ForEach-Object { "$_" }) -join "`n").Trim() if ($exitCode -eq 0 -and $token) { return @{ Ok = $true; Token = $token; Detail = ''; Empty = $false } } # An exit-0 with no token is an unusable session, not a transport error. return @{ Ok = $false; Token = ''; Detail = $detail; Empty = ($exitCode -eq 0) } } # Operator-facing explanation for a token acquisition that could not be # recovered. Every line is shared verbatim with install-minions.sh. function Write-AzFailure($classification, $detail) { $lines = switch ($classification) { 'az-missing' { @('The Azure CLI (az) was not found on PATH.', 'Install it from https://aka.ms/azure-cli, then re-run this installer.') } 'authorization' { @('Azure sign-in worked, but this identity is not authorized for Azure DevOps.', 'This is NOT a sign-in problem -- signing in again will not change it.', 'Ask for Azure DevOps access in the office organization, then re-run.') } 'sign-in-required-noninteractive' { @('No usable Azure CLI session, and this terminal cannot prompt for one.', 'Sign in once from an interactive terminal, then re-run this installer:', $AzLoginCommand) } 'login-failed' { @('Interactive sign-in failed.', 'Run it yourself, confirm it completes, then re-run this installer:', $AzLoginCommand) } 'sign-in-failed' { @('Sign-in completed, but Azure DevOps still refused to issue a token.', 'Your account signed in successfully, so this is an access problem, not a login one.', 'Ask for Azure DevOps access in the office organization, then re-run.') } default { @('Could not acquire an Azure DevOps token from the Azure CLI.', 'This was not a missing sign-in, so signing in again is unlikely to help.') } } $cause = "$detail".Trim() if ($cause) { $lines += $cause } Write-Fail ($lines -join "`n ") } function Get-AdoToken { $attempt = Invoke-AzTokenAttempt if ($attempt.Ok) { return $attempt.Token } $classification = if ($attempt.Empty) { 'sign-in-required' } else { Get-AzTokenClassification $attempt.Detail } if ($classification -ne 'sign-in-required') { Write-AzFailure $classification $attempt.Detail } if (-not (Test-InteractiveSession)) { Write-AzFailure 'sign-in-required-noninteractive' $attempt.Detail } Write-Step "No usable Azure CLI session -- signing in once with $AzLoginCommand" # stdio is inherited so the prompt reaches this terminal. `az login` prints # no token, so nothing secret can cross this stream. `Out-Host` keeps its # output off the pipeline, which carries only the token this returns. & az @AzLoginArgumentList | Out-Host if ($LASTEXITCODE -ne 0) { Write-AzFailure 'login-failed' '' } $retry = Invoke-AzTokenAttempt if ($retry.Ok) { return $retry.Token } Write-AzFailure 'sign-in-failed' $retry.Detail } # ============================================================================ # Launcher contract # ============================================================================ # A digest of everything else in this file, written by `bun run minions:channel:sync`. # Do not edit by hand: a hand-set revision is exactly the stale stamp this exists # to prevent. $LauncherRevision = 'sha256:b6c13e7f5cf2' # Printed before the pin is validated and before anything is requested, # downloaded, or installed, so the console output of any run -- successful or # not -- names the launcher that produced it. A months-old copy in a downloads # folder is otherwise indistinguishable from the current one. Write-Step "Minions launcher install-minions.ps1 $LauncherRevision" # ============================================================================ # Resolve the version to install # ============================================================================ # Pinning is opt-in: with no pin this resolves the newest published version # exactly as before, and with one it installs that exact build -- the only way # back to a known-good older release. The flag wins over the environment so a # shell-wide pin never outranks the version typed on this invocation. $requestedVersion = if ($Version) { $Version } else { $env:MINIONS_INSTALL_VERSION } # .NET's `$` also matches immediately before a trailing newline, so trim rather # than let "1.2.3anything" through as a valid pin. $requestedVersion = "$requestedVersion".Trim() # Checked here -- before the token request, the staging directory, the download, # and the migrator -- so a typo costs the machine nothing. if ($requestedVersion -and $requestedVersion -notmatch $VersionPinPattern) { Write-Fail "'$requestedVersion' is not an exact published version. Pass -Version 1.2.3 (or set MINIONS_INSTALL_VERSION), or omit both to install the newest published version." } $versionSpec = if ($requestedVersion) { $requestedVersion } else { 'latest' } # ============================================================================ # Prerequisites # ============================================================================ Write-Step "Checking prerequisites" Assert-Command 'node' 'Install Node.js 22.5 or newer.' Assert-Command 'npm' 'Install Node.js 22.5 or newer.' Assert-Command 'az' 'Install the Azure CLI from https://aka.ms/azure-cli.' # ============================================================================ # Acquire a short-lived feed token # ============================================================================ Write-Step "Acquiring a short-lived Azure DevOps token" $token = Get-AdoToken # ============================================================================ # Stage the package without touching the global prefix # ============================================================================ # Deliberately NOT a global install. npm refuses to overwrite a `minions` bin # shim owned by an earlier public-npm install and aborts with EEXIST, so the # wrapper stages the package locally and lets the canonical installer -- which # backs state up and removes that earlier install first -- own the global step. $tempRoot = Join-Path ([IO.Path]::GetTempPath()) "minions-$([guid]::NewGuid())" $tempDir = (New-Item -ItemType Directory -Path $tempRoot).FullName $npmrc = Join-Path $tempDir '.npmrc' $userConfig = if ($env:NPM_CONFIG_USERCONFIG) { $env:NPM_CONFIG_USERCONFIG } else { Join-Path $HOME '.npmrc' } $inherited = if (Test-Path -LiteralPath $userConfig) { # Decode as UTF-8 explicitly and drop only a leading BOM. `Get-Content -Raw` would # fall back to ANSI on Windows PowerShell 5.1 for a BOM-less file and mangle a # non-ASCII prefix, and a surviving BOM makes npm ignore the first setting. [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($userConfig)).TrimStart([char]0xFEFF) } else { '' } try { # `-Encoding ascii` would turn every non-ASCII byte in the inherited config into # `?`, so a global prefix under a non-ASCII profile name would install to a path # `npm root -g` cannot resolve below. `Set-Content -Encoding utf8` is not the fix # either: it emits a BOM on Windows PowerShell 5.1 and npm then ignores the first # setting. Write UTF-8 without a BOM so both hosts round-trip the config verbatim. [IO.File]::WriteAllText( $npmrc, (@( $inherited, "${PackageScope}:registry=$FeedRegistry", "${FeedAuthKey}:_authToken=$token", "${FeedAuthKey}:username=minions", "${FeedAuthKey}:_password=$([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($token)))", "${FeedAuthKey}:email=minions@internal.invalid" ) -join [Environment]::NewLine) + [Environment]::NewLine, (New-Object Text.UTF8Encoding $false) ) # Proves feed read access before anything on the machine changes, so a missing # permission fails safely and leaves the host untouched. The resolved version is # reused below so the staged installer and the package it installs cannot drift # apart across a publish that lands mid-run. Write-Step "Verifying $FeedName read access for $PackageName@$versionSpec" $targetVersion = npm view "$PackageName@$versionSpec" version --registry $FeedRegistry --userconfig $npmrc if ($LASTEXITCODE -ne 0) { # Deliberately not a sign-in prescription: a token was already issued, so # looping back through `az login` would change nothing here. Write-Fail "Could not read $PackageName@$versionSpec from $FeedName. Request Azure Artifacts read permission on the feed -- or check that a pinned version exists -- then re-run. The Azure sign-in already worked, so this is feed read permission, not a login." } $targetVersion = ($targetVersion | Select-Object -Last 1 | ForEach-Object { "$_".Trim() }) if (-not $targetVersion) { Write-Fail "$FeedName did not report a version for $PackageName@$versionSpec." } # A pin that resolves to some other build is the feed answering a different # question; installing that answer would silently defeat the pin. if ($requestedVersion -and $targetVersion -ne $requestedVersion) { Write-Fail "$FeedName resolved $PackageName@$requestedVersion to $targetVersion; refusing to install a version other than the pinned one." } # `--prefix` keeps this install inside the throwaway directory: its bin links # land in `$tempDir/node_modules/.bin`, never beside the global `minions` shim. # `--global=false` neutralises a `global=true` inherited from the caller's npmrc # above, which would otherwise switch npm to the global layout under that prefix # and leave the hand-off below unable to find the staged migrator. Write-Step "Staging $PackageName@$targetVersion" npm install "$PackageName@$targetVersion" --prefix $tempDir --global=false --no-save --ignore-scripts --no-audit --no-fund --registry $FeedRegistry --userconfig $npmrc if ($LASTEXITCODE -ne 0) { Write-Fail "Could not stage $PackageName@$targetVersion from $FeedName." } # The canonical installer acquires its own short-lived token, so the staging # config has no reason to outlive the staging step. Remove-Item -LiteralPath $npmrc -Force -ErrorAction SilentlyContinue Remove-Variable token -ErrorAction SilentlyContinue # ======================================================================== # Run the canonical installer # ======================================================================== # It owns the whole cutover and its ordering: state backup, removal of an # earlier public-npm install, orphaned-shim repair, the global install of the # internal package, `minions init`, and a health-verified restart. Idempotent: # re-run this script any time to upgrade. Write-Step "Running the canonical Minions installer" node (Join-Path $tempDir "node_modules/$PackageName/$CanonicalInstallerPath") --version $targetVersion if ($LASTEXITCODE -ne 0) { Write-Fail 'The canonical Minions installer failed. Re-run this script after resolving the reported issue.' } } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Variable token -ErrorAction SilentlyContinue } Write-Ok "Minions is installed. Next: minions doctor"