# Takumi Guard token provisioning # # Two invocation modes: # # 1. Legacy (all-in-one) mode -- back-compat with versions <= 0.4.0: # # $env:TG_BOT_API_KEY="..." ; .\takumi-guard-setup-{VERSION}.ps1 [SCOPES] # # Mints (or reuses) a token and configures all package managers in one # invocation. The pre-0.5.0 contract is preserved bit-identically: same # stdout log lines (Write-Output, kept on stdout for MDM compatibility), # same exit codes, same backup/rollback behavior. # # 2. Primitive subcommand mode -- introduced in 0.5.0 for composing custom # deployment flows (e.g. one-token-per-device on multi-user machines): # # takumi-guard-setup-{VERSION}.ps1 precheck [SCOPES] -- exit 0 if anything is configurable # takumi-guard-setup-{VERSION}.ps1 discover -- print existing tg_org_* tokens (one per line) # takumi-guard-setup-{VERSION}.ps1 verify -- print active|revoked|unknown # takumi-guard-setup-{VERSION}.ps1 issue -- mint a new token; print it # takumi-guard-setup-{VERSION}.ps1 install [SCOPES] -- write package manager configs # takumi-guard-setup-{VERSION}.ps1 healthcheck [SCOPES] # -- verify Guard is working (read-only) # # In subcommand mode, machine-readable results are printed to stdout # ([Console]::Out.WriteLine) and log messages are printed to stderr # ([Console]::Error.WriteLine). The legacy mode keeps every line on # stdout via Write-Output because some MDM / remote-shell hosts capture # only stdout. # # Supported: # npm ecosystem -- npm, pnpm, yarn v2+, bun # PyPI ecosystem -- pip, uv, poetry # RubyGems ecosystem -- Bundler # Go ecosystem -- go (GOPROXY env file + %USERPROFILE%\_netrc credential) # Composer ecosystem -- composer (config.json repositories + auth.json credential) # # Arguments (positional): # BOT_ID Required in legacy mode (in subcommand mode it is passed via # TG_BOT_ID for verify / issue). # USER_IDENTIFIER Unique device/user identifier. # Allowed characters: a-z, A-Z, 0-9, hyphen, underscore, dot, at sign, plus. # Length: 4-255 characters. # SCOPES Comma-separated ecosystems to configure # (default: npm,pypi,rubygems,golang,packagist). # # Environment: # TG_BOT_API_KEY Required. Bot API key. Passed via env to # avoid shell history / process listing exposure. # TG_BOT_ID Required for `verify` / `issue` subcommands. Bot ID # (positional in legacy mode). # TG_PREMINTED_TOKEN Optional (legacy mode only). Pre-minted org token to # use instead of calling the API. Validated against # tg_org_ format before use. Subcommand callers should # pass the token to `install` directly instead. # USER_HOME Optional. Override the user home directory. When unset, # $env:USERPROFILE is used. Required when the script is # invoked outside the target user's session (e.g. an MDM # agent that runs as LocalSystem); without it config files # would land under the SYSTEM profile and never reach the # real user. Honored by every subcommand and by legacy mode. # TG_API_BASE_URL Optional. Override the API base URL (for staging / # testing). # TG_DIRECT_WRITE Optional. Set to 1 / true / yes (case-insensitive) to write # config files directly instead of running a package-manager # CLI (npm config set / poetry config / go env -w), and to # pre-place config for every requested scope even when no tool # or config file is present yet, so Guard activates the moment # the tool is installed. Honored by legacy mode, `precheck`, # and `install`. # # Idempotency: # Re-running is safe in both legacy mode and the `install` subcommand: if a # tg_org_* token is already present in a config file, that file is left # untouched; other requested scopes are still updated as needed. Legacy mode # additionally reuses an existing active token instead of minting a new one # (the `install` subcommand always uses the token passed to it). # # Backup and rollback: # Before modifying an existing config file, a timestamped backup is created # next to the original (e.g. .npmrc-backup-20260408-162351). These backups # are preserved even if the script succeeds, so you can manually restore the # previous state at any time by copying the backup file back. # # If the script fails midway through, all changes made so far are automatically # rolled back to the pre-execution state. No manual intervention is needed. # # Config file handling: # - If a config file already exists, it is updated regardless of whether the # corresponding tool is installed. Package managers do not remove their config # files on uninstall, and pre-placing config ensures Guard is active as soon # as the tool is (re)installed. # - If a config file does not exist, it is created only if the tool is # installed. # - Non-Guard settings in existing config files (e.g. ignore-scripts, # min-release-age) are preserved. # # Prerequisites: # PowerShell 5.1 or later. # In subcommand mode the names are overloaded: $BotId carries the # subcommand keyword (`precheck`/`discover`/...), and $UserIdentifier / # $Scopes carry the subcommand's positional argument(s). The dispatch # below interprets them; BOT_ID always begins with `BT` so the overloading # is unambiguous. param( [Parameter(Position=0)][string]$BotId, [Parameter(Position=1)][string]$UserIdentifier, [Parameter(Position=2)][string]$Scopes, [Parameter(Position=3)][string]$ExtraArg ) $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Environment defaults + LocalSystem fail-fast + APPDATA override # --------------------------------------------------------------------------- # These steps run before any function definition so the $env:APPDATA / # $env:LOCALAPPDATA override is in effect for everything below. $ApiKey = $env:TG_BOT_API_KEY if ($env:USER_HOME) { $UserHome = $env:USER_HOME } else { $UserHome = $env:USERPROFILE } if (-not $UserHome) { throw "Could not determine user home directory" } # Direct-write mode (opt-in, TG_DIRECT_WRITE). Set to 1, # true, or yes (case-insensitive) to enable; any other value, empty, or unset # leaves it disabled. When enabled, Install-Configs writes config files directly # instead of running a package-manager CLI; detection and which files are # written are unchanged. $DirectWrite = (@('1', 'true', 'yes') -contains ("$env:TG_DIRECT_WRITE").Trim().ToLowerInvariant()) # When USER_HOME is overridden (the MDM SYSTEM-context wrapper pattern), # $env:APPDATA / LOCALAPPDATA still resolve to the SYSTEM systemprofile. # Override them so child processes the script invokes during configuration # (poetry's Python user-site lookup, npm's userconfig path resolution) # find the target developer's directories instead of SYSTEM's. if ($env:USER_HOME) { $env:APPDATA = Join-Path $UserHome "AppData\Roaming" $env:LOCALAPPDATA = Join-Path $UserHome "AppData\Local" } # The LocalSystem fail-fast (SID S-1-5-18 without USER_HOME) is enforced # AFTER dispatch so that the read-only API subcommands (`verify`, # `issue`) -- which the per-device wrapper invokes from a SYSTEM # context without USER_HOME on purpose -- are not blocked by it. The # guard still fires for `install` and legacy mode, where a missing # USER_HOME would route config writes into the systemprofile path. $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value # Base URL for the Takumi Guard org-user token endpoint family. Both the # mint path (POST $ApiBaseUrl) and the validation path (POST $ApiBaseUrl/status) # derive from this value. if ($env:TG_API_BASE_URL) { $ApiBaseUrl = $env:TG_API_BASE_URL } else { $ApiBaseUrl = "https://apiv2.cloud.shisho.dev/v1/guard/tokens/org-user" } # --------------------------------------------------------------------------- # Force-mode helpers # --------------------------------------------------------------------------- # True when Install-Configs may invoke a CLI; false in direct-write mode. Each # CLI call site falls through to its direct-write branch when this is false. function Should-UseCli { return (-not $DirectWrite) } # True in direct-write mode, so the gates accept every requested scope # regardless of which tools or config files are detected. function Should-ProvisionAll { return $DirectWrite } # Write npm-style registry + nerf-darted authToken to an .npmrc-format file # (npm/pnpm). Each of the two keys is replaced in place when present and # appended otherwise. function Set-NpmrcToken { param([string]$Path, [string]$Token) if ((Test-Path $Path) -and (Select-String -Path $Path -Pattern '^//npm\.flatt\.tech/:_authToken=' -Quiet)) { (Get-Content $Path) -replace '^//npm\.flatt\.tech/:_authToken=.*', "//npm.flatt.tech/:_authToken=$Token" | Set-Content $Path } else { Add-NewlineIfMissing $Path Add-Content -Path $Path -Value "//npm.flatt.tech/:_authToken=$Token" } if (-not ((Test-Path $Path) -and (Select-String -Path $Path -Pattern '^registry=https://npm\.flatt\.tech/?$' -Quiet))) { Add-NewlineIfMissing $Path Add-Content -Path $Path -Value "registry=https://npm.flatt.tech/" } } # Number of leading spaces in a line. function Get-LeadingSpaceCount { param([string]$Line) return ([regex]::Match($Line, '^ *')).Length } # The yarn v2+ (.yarnrc.yml) lines that result from configuring Guard in Path # (which may be missing) with Token. The credential is scoped to Guard's host # with npmAlwaysAuth, a root-level Guard token (tg_*) is removed, Guard's entry # is the first child of npmRegistries, and every other line is kept. Running # the script again leaves the file unchanged. # Keys are matched case-sensitively. A flow mapping with content # (`npmRegistries: { ... }`) and a quoted root key are not recognised. function Get-YarnrcContent { param([string]$Path, [string]$Token) $lines = @() # Read and written as UTF-8, the encoding Yarn uses. if (Test-Path $Path) { $lines = @([IO.File]::ReadAllLines($Path, [System.Text.UTF8Encoding]::new($false))) } $blankRe = '^[ \t]*(#.*)?$' $hostRe = '^ *["'']?(https?:)?//npm\.flatt\.tech/?["'']?:[ \t]*(#.*)?$' $server = 'npmRegistryServer: "https://npm.flatt.tech/"' # Pass 1: locate npmRegistries and the indentation of its children. $reg = -1; $ci = 0 for ($i = 0; $i -lt $lines.Count; $i++) { if ($reg -lt 0) { if ($lines[$i] -cmatch '^npmRegistries:[ \t]*(\{[ \t]*\})?[ \t]*(#.*)?$') { $reg = $i } continue } if ($ci -eq 0 -and $lines[$i] -cnotmatch $blankRe) { $n = Get-LeadingSpaceCount $lines[$i] $ci = if ($n -gt 0) { $n } else { 2 } } } if ($reg -ge 0 -and $ci -eq 0) { $ci = 2 } if ($reg -lt 0) { $ci = 2 } $pad = ' ' * $ci $entry = @("$pad`"https://npm.flatt.tech/`":", "$pad npmAuthToken: `"$Token`"", "$pad npmAlwaysAuth: true") # Pass 2: rewrite. $out = New-Object System.Collections.Generic.List[string] $skip = $false; $inReg = $false; $hasServer = $false for ($i = 0; $i -lt $lines.Count; $i++) { $l = $lines[$i] $n = Get-LeadingSpaceCount $l if ($skip -and (($l -cmatch '^[ \t]*$') -or $n -gt $ci)) { continue } $skip = $false if ($inReg -and ($l -cnotmatch $blankRe) -and $n -eq 0) { $inReg = $false } if ($i -eq $reg) { $out.Add('npmRegistries:'); $entry | ForEach-Object { $out.Add($_) }; $inReg = $true; continue } if ($inReg -and $n -eq $ci -and $l -cmatch $hostRe) { $skip = $true; continue } if ($l -cmatch '^npmRegistryServer:') { $out.Add($server); $hasServer = $true; continue } if ($l -cmatch '^npmAuthToken:[ \t]*["'']?tg_') { continue } $out.Add($l) } if (-not $hasServer) { $out.Add($server) } if ($reg -lt 0) { $out.Add('npmRegistries:'); $entry | ForEach-Object { $out.Add($_) } } return $out.ToArray() } # Configure Guard in the yarn v2+ file Path with Token. function Set-YarnrcToken { param([string]$Path, [string]$Token) $content = @(Get-YarnrcContent $Path $Token) [IO.File]::WriteAllLines($Path, $content, [System.Text.UTF8Encoding]::new($false)) } # True when Path already holds exactly what Set-YarnrcToken would write. function Test-YarnrcCurrent { param([string]$Path, [string]$Token) if (-not (Test-Path $Path)) { return $false } $current = @([IO.File]::ReadAllLines($Path, [System.Text.UTF8Encoding]::new($false))) -join "`n" $wanted = @(Get-YarnrcContent $Path $Token) -join "`n" return ($current -ceq $wanted) } # --------------------------------------------------------------------------- # Dispatch + argument parsing # --------------------------------------------------------------------------- function Show-Usage { $self = if ($PSCommandPath) { Split-Path -Leaf $PSCommandPath } else { 'takumi-guard-setup.ps1' } $u = @" Usage: Legacy (one-shot): `$env:TG_BOT_API_KEY="..." ; .\$self [SCOPES] Primitive subcommands: .\$self precheck [SCOPES] .\$self discover .\$self verify (env: TG_BOT_ID, TG_BOT_API_KEY) .\$self issue (env: TG_BOT_ID, TG_BOT_API_KEY) .\$self install [SCOPES] .\$self healthcheck [SCOPES] (read-only, no credentials needed) Set TG_DIRECT_WRITE=1 to write config files directly instead of running a package-manager CLI (npm config set / poetry config / go env -w), and to pre-place config for every requested scope even when the tool is not yet installed. Existing non-Guard settings are merged and preserved. Honored by legacy mode, precheck, and install. "@ [Console]::Error.WriteLine($u) } $Subcommand = $null $SubToken = '' # Dispatch on $BotId. BOT_ID from the Shisho Cloud console always begins # with `BT`, so it can never collide with a subcommand keyword. switch ($BotId) { 'precheck' { $Subcommand = 'precheck' # `setup.ps1 precheck [SCOPES]` -- $UserIdentifier holds SCOPES. if ($UserIdentifier) { $Scopes = $UserIdentifier } if (-not $Scopes) { $Scopes = 'npm,pypi,rubygems,golang,packagist' } } 'discover' { $Subcommand = 'discover' } 'verify' { $Subcommand = 'verify' # `setup.ps1 verify ` -- $UserIdentifier holds the token. if (-not $UserIdentifier) { Show-Usage; exit 1 } $SubToken = $UserIdentifier $BotId = $env:TG_BOT_ID } 'issue' { $Subcommand = 'issue' # `setup.ps1 issue ` -- $UserIdentifier holds the id. if (-not $UserIdentifier) { Show-Usage; exit 1 } $BotId = $env:TG_BOT_ID } 'install' { $Subcommand = 'install' # `setup.ps1 install [SCOPES]` -- $UserIdentifier holds the # token, $Scopes holds the optional scope list (already positional). if (-not $UserIdentifier) { Show-Usage; exit 1 } $SubToken = $UserIdentifier if (-not $Scopes) { $Scopes = 'npm,pypi,rubygems,golang,packagist' } } 'healthcheck' { $Subcommand = 'healthcheck' # `setup.ps1 healthcheck [SCOPES]` -- $UserIdentifier holds SCOPES. if ($UserIdentifier) { $Scopes = $UserIdentifier } if (-not $Scopes) { $Scopes = 'npm,pypi,rubygems,golang' } } { $_ -in @('-h', '--help', 'help') } { Show-Usage exit 0 } '' { Show-Usage exit 1 } default { # Legacy mode: $BotId, $UserIdentifier, $Scopes # bind from named or positional args directly. if (-not $UserIdentifier) { Show-Usage; exit 1 } if (-not $Scopes) { $Scopes = 'npm,pypi,rubygems,golang,packagist' } } } # LocalSystem fail-fast, applied here so it does not block the read-only # API subcommands (verify, issue) the per-device wrapper invokes from a # SYSTEM context without USER_HOME. Legacy mode and the write-path # subcommands (precheck, discover, install) still need USER_HOME to be # explicit so config writes do not land in the systemprofile. `healthcheck` # needs it too: without a target profile it would inspect the systemprofile # and report every correctly configured device as failing. if ($Subcommand -ne 'verify' -and $Subcommand -ne 'issue') { if ($currentSid -eq 'S-1-5-18' -and -not $env:USER_HOME) { throw "[Error] Running as LocalSystem (SID S-1-5-18) without USER_HOME. Set USER_HOME to the target user's profile path (e.g. C:\Users\alice) before invoking this script." } } # --------------------------------------------------------------------------- # Logging stream routing # --------------------------------------------------------------------------- # In legacy mode, `Log` writes to stdout via Write-Output so [Error]/[OK]/ # [Skip] lines remain visible to MDM hosts that capture only stdout. In # subcommand mode, `Log` routes to stderr via [Console]::Error.WriteLine and # the structured result is emitted via Write-Result to real stdout. if ($Subcommand) { function Log { param([string]$Message) [Console]::Error.WriteLine($Message) } } else { function Log { param([string]$Message) Write-Output $Message } } function Write-Result { param([string]$Message) [Console]::Out.WriteLine($Message) } # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- function Test-SafeString { param([string]$Label, [string]$Value) if ($Value -notmatch '^[0-9a-zA-Z._@+\-]+$') { throw "[Error] $Label contains invalid characters" } } function Test-UserIdentifier { param([string]$Id) if ($Id.Length -lt 4 -or $Id.Length -gt 255) { throw "[Error] USER_IDENTIFIER must be 4-255 characters (got $($Id.Length))" } Test-SafeString "USER_IDENTIFIER" $Id } function Require-ApiCredentials { if (-not $BotId) { [Console]::Error.WriteLine("[Error] TG_BOT_ID environment variable is required for $Subcommand") exit 1 } if (-not $ApiKey) { [Console]::Error.WriteLine("[Error] TG_BOT_API_KEY environment variable is required for $Subcommand") exit 1 } Test-SafeString "BOT_ID" $BotId Test-SafeString "TG_BOT_API_KEY" $ApiKey } # --------------------------------------------------------------------------- # API client (verify / issue) # --------------------------------------------------------------------------- # Probe the API to classify the supplied tg_org_* token as one of: # active -- server confirmed the token is currently active. # revoked -- server confirmed the token is not active (revoked, never # issued, or owned by a different organisation). # unknown -- anything else: network failure, timeout, 5xx, 401, malformed # body on a 200 response, locally malformed token. The caller # MUST treat this as "we cannot safely proceed" and abort. function Get-OrgTokenStatus { param([string]$Token) # Returns one of "active" / "revoked" / "unknown" via the single # `return` below. Log lines are the caller's responsibility -- any # Write-Output here would corrupt the string return into [object[]]. if ($Token -notmatch '^tg_org_[A-Za-z0-9_-]+$') { return "unknown" } $body = @{ bot_id = $BotId api_key = $ApiKey token = $Token } | ConvertTo-Json -Compress $statusCode = 0 $content = "" try { $resp = Invoke-WebRequest -Uri "$ApiBaseUrl/status" -Method Post -Body $body ` -ContentType "application/json" -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop $statusCode = [int]$resp.StatusCode $content = ($resp.Content -as [string]) } catch { # A non-2xx response raises an exception carrying the status code and # body; the body is needed by the 404 branch below. Any other failure # (DNS, timeout) has no response and stays unknown. if ($_.Exception.Response) { try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { return "unknown" } if ($null -ne $_.ErrorDetails -and $null -ne $_.ErrorDetails.Message) { $content = $_.ErrorDetails.Message } else { try { $stream = $_.Exception.Response.GetResponseStream() if ($null -ne $stream) { $reader = New-Object System.IO.StreamReader($stream) $content = $reader.ReadToEnd() $reader.Close() } } catch { $content = "" } } } else { return "unknown" } } switch ($statusCode) { 200 { # A 200 with the documented status payload signals "active". Any # 200 carrying an unexpected body is treated as "unknown" -- # fail-closed against an upstream proxy that rewrites our # response. if ($null -ne $content -and $content -cmatch '"created_at"\s*:\s*"[^"]+"') { return "active" } return "unknown" } 404 { # A 404 is "revoked" only when the body is the /status JSON error # (an `"error"` field) containing `token not active`. Any other # 404, such as a plain-text or HTML page, is "unknown", so it never # leads to issuing a new token. if ($null -ne $content ` -and $content -cmatch '"error"\s*:\s*"[^"]+"' ` -and $content -clike '*token not active*') { return "revoked" } return "unknown" } default { # 401, 403, 5xx, or status 0 (no response): classify as unknown. # Unknown means "do NOT re-mint". return "unknown" } } } # Mint a new org-user token via the public API. Returns the token string on # success; emits `[Error]` lines via Log and throws on failure. Does NOT # emit the legacy "[OK] Token minted" line -- the caller is responsible for # that so legacy mode and the `issue` subcommand can differ in surface logs. function New-OrgToken { param([string]$UserId) $body = @{ bot_id = $BotId api_key = $ApiKey user_identifier = $UserId } | ConvertTo-Json try { $response = Invoke-RestMethod -Uri $ApiBaseUrl -Method Post -Body $body -ContentType "application/json" $token = $response.token } catch { $statusCode = $_.Exception.Response.StatusCode.value__ Log "[Error] Token API returned HTTP $statusCode" Log $_.ErrorDetails.Message throw } if (-not $token) { throw "[Error] Failed to extract token from API response" } # Validate token format if ($token -notmatch '^tg_org_[A-Za-z0-9_-]{20,}$') { throw "[Error] Token format unexpected" } return $token } # --------------------------------------------------------------------------- # Scope filter + tool discovery # --------------------------------------------------------------------------- function Has-Scope { param([string]$Name) return (",$Scopes," -like "*,$Name,*") } # Probe whether `py.exe` (the Python launcher) has a usable Python with pip. # A py.exe with no registered Python counts as "no pip" rather than aborting # the script. function Test-PyHasPip { if (-not (Get-Command py -ErrorAction SilentlyContinue)) { return $false } try { & py -m pip --version *> $null } catch { return $false } return $LASTEXITCODE -eq 0 } # Locate a command via PATH or well-known install directories. # MDM tools may execute this script in a non-interactive session where the # user's PATH modifications are not loaded, so Get-Command alone may miss # installed tools. # Returns the directory containing the command when found; $null otherwise. function Find-CommandDir { param([string]$Name) # Only accept real executables with absolute Source paths. Aliases/functions/cmdlets # would return non-path Sources that would yield nonsense from Split-Path -Parent. $cmd = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd -and $cmd.Source -and [System.IO.Path]::IsPathRooted($cmd.Source)) { return (Split-Path -Parent $cmd.Source) } # Derive user-profile-relative AppData paths from USER_HOME, so user-scope # tool installs (scoop, corepack-managed pnpm/yarn, nvm, .bun, .rbenv, # .asdf, mise, per-user RubyInstaller) are found under the target user's # profile even when the script runs as SYSTEM. $userAppData = if ($env:USER_HOME) { Join-Path $UserHome "AppData\Roaming" } else { $env:APPDATA } $userLocalAppData = if ($env:USER_HOME) { Join-Path $UserHome "AppData\Local" } else { $env:LOCALAPPDATA } $wellKnownDirs = @( "$userLocalAppData\pnpm" "$userAppData\npm" "$UserHome\.bun\bin" "$UserHome\.local\bin" "$userAppData\pypoetry\venv\Scripts" "$userAppData\Python\Scripts" "${env:ProgramFiles}\Bun" "${env:ProgramFiles(x86)}\Yarn\bin" "$userLocalAppData\Yarn\bin" "$UserHome\scoop\apps\ruby\current\bin" "$UserHome\scoop\shims" # Cross-platform Ruby version managers. "$UserHome\.rbenv\shims" "$UserHome\.asdf\shims" "$userLocalAppData\mise\shims" "$UserHome\.local\share\mise\shims" # Go toolchain: official installer drops here system-wide, and the # user-scope tarball install lands under LocalAppData\Programs. "${env:ProgramFiles}\Go\bin" "$userLocalAppData\Programs\go\bin" # Composer: the official Windows installer (Composer-Setup.exe) drops # composer.bat here system-wide. "${env:ProgramData}\ComposerSetup\bin" ) # Conditionally include env-derived paths only when the env var is set. # PowerShell string-interpolates an undefined env var to empty, so eagerly # appending "$env:RBENV_ROOT\shims" would yield "\shims", which resolves # against the current drive (e.g. C:\shims) and could yield false positives # if such a directory happens to contain a matching binary. if ($env:RBENV_ROOT) { $wellKnownDirs += "$env:RBENV_ROOT\shims" } if ($env:GOROOT) { $wellKnownDirs += "$env:GOROOT\bin" } # `ps1` covers PowerShell-script wrappers that scoop / corepack drop # alongside the binary (e.g. corepack-managed pnpm.ps1, yarn.ps1). $exts = @("exe", "cmd", "bat", "ps1") foreach ($dir in $wellKnownDirs) { # Skip empty / drive-relative ("\foo") entries that arise when an env # var was unset at interpolation time. UNC paths ("\\server\share") # are intentionally allowed. if ([string]::IsNullOrWhiteSpace($dir) -or $dir -match '^\\[^\\]') { continue } foreach ($ext in $exts) { if (Test-Path (Join-Path $dir "$Name.$ext")) { return $dir } } } # Wildcard-expanded locations: # scoop\apps\\current[\bin|\Scripts]: scoop installs tools in # per-app dirs and adds them to the user's PATH; under SYSTEM-context # execution they are invisible without an explicit traversal. # `corepack prepare pnpm@... --activate` drops pnpm.ps1 inside the # nodejs-lts current dir, so this also catches corepack tools. # Python\Python*\Scripts: `pip install --user` lands here under a # version-bumped subdir (Python314, Python313, ...) inside Roaming # AppData. # Ruby*-x64\bin: system-wide / per-user RubyInstaller / Chocolatey # installs. foreach ($pattern in @( "$UserHome\scoop\apps\*\current", "$UserHome\scoop\apps\*\current\bin", "$UserHome\scoop\apps\*\current\Scripts", "$userAppData\Python\Python*\Scripts", "C:\Ruby*-x64\bin", "C:\Ruby*\bin", "C:\tools\ruby*\bin", "$userLocalAppData\Programs\Ruby*-x64\bin", "${env:ProgramFiles}\Ruby*-x64\bin")) { foreach ($subDir in (Get-ChildItem -Path $pattern -Directory -ErrorAction SilentlyContinue)) { foreach ($ext in $exts) { if (Test-Path (Join-Path $subDir.FullName "$Name.$ext")) { return $subDir.FullName } } } } return $null } # Boolean wrapper around Find-CommandDir for callers that only need hit/miss. function Find-Command { param([string]$Name) return [bool](Find-CommandDir $Name) } # Resolve the Bundler user config file path (BUNDLE_USER_CONFIG > BUNDLE_USER_HOME\config > $UserHome\.bundle\config). # Bundler does NOT follow XDG_CONFIG_HOME. function Get-BundleConfigPath { if ($env:BUNDLE_USER_CONFIG) { return $env:BUNDLE_USER_CONFIG } if ($env:BUNDLE_USER_HOME) { return (Join-Path $env:BUNDLE_USER_HOME "config") } return (Join-Path $UserHome ".bundle\config") } $BundleConfigPath = Get-BundleConfigPath # Resolve the netrc path the go toolchain reads for GOPROXY HTTP Basic # credentials. The toolchain follows curl convention: on Windows it prefers # %USERPROFILE%\_netrc and falls back to %USERPROFILE%\.netrc. The NETRC # environment variable wins when set. function Get-NetrcPath { if ($env:NETRC) { return $env:NETRC } $underscore = Join-Path $UserHome "_netrc" $dot = Join-Path $UserHome ".netrc" if (Test-Path $underscore) { return $underscore } if (Test-Path $dot) { return $dot } # Neither exists yet: prefer the Windows convention for the create path. return $underscore } $NetrcPath = Get-NetrcPath # Resolve the Go env file path. The toolchain answer (go env GOENV) is # authoritative when the binary is available; fall back to the platform # default (%AppData%\go\env on Windows). $env:APPDATA has already been # overridden to $UserHome\AppData\Roaming at script init when USER_HOME is # set, so the default path lands under the target developer's profile # without further work. function Get-GoEnvPath { $goDir = Find-CommandDir "go" if ($goDir) { # `go env` is a native command; run it with # $ErrorActionPreference='Continue' so benign toolchain stderr (e.g. # a toolchain download notice) cannot abort script init. $resolved = $null $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $resolved = (& (Join-Path $goDir "go.exe") env GOENV 2>$null) } catch { $resolved = $null } finally { $ErrorActionPreference = $prevEap } if ($resolved -and [System.IO.Path]::IsPathRooted($resolved)) { return $resolved } } if ($env:GOENV -and [System.IO.Path]::IsPathRooted($env:GOENV)) { return $env:GOENV } return (Join-Path $env:APPDATA "go\env") } $GoEnvPath = Get-GoEnvPath # Resolve COMPOSER_HOME (the directory holding config.json / auth.json): on # Windows Composer uses %APPDATA%\Composer, and an explicit $env:COMPOSER_HOME # wins. $env:APPDATA has already been overridden to $UserHome\AppData\Roaming # at script init when USER_HOME is set, so the path lands under the target # developer's profile. function Get-ComposerHomeDir { if ($env:COMPOSER_HOME) { return $env:COMPOSER_HOME } return (Join-Path $env:APPDATA "Composer") } $ComposerHomeDir = Get-ComposerHomeDir # Resolve the principal that Restrict-FilePermission grants file access to. # When setup.ps1 runs as the target user (the common case), this is just the # executing identity. When it runs as SYSTEM with USER_HOME pointing at a # target developer's profile (the MDM wrapper pattern documented in the user # guide), the grant must follow the profile owner. Granting only SYSTEM and # Administrators would lock the developer out of their own token-bearing # config files. The profile owner is resolved via Win32_UserProfile so that # renamed accounts and roaming profiles are handled correctly. The SID-string # form (`*S-1-5-...`) is used as the fallback for orphaned profiles whose # SIDs no longer resolve to a printable NT account. function Get-FileGrantee { $current = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($current.User.Value -ne 'S-1-5-18') { return $current.Name } $profile = Get-CimInstance -ClassName Win32_UserProfile ` -Filter "Special = false" -ErrorAction SilentlyContinue | Where-Object { $_.LocalPath -ieq $env:USER_HOME } | Select-Object -First 1 if (-not $profile -or -not $profile.SID) { throw "no Win32_UserProfile entry matches USER_HOME ($($env:USER_HOME)); ensure USER_HOME points to a real user profile directory" } try { return (New-Object System.Security.Principal.SecurityIdentifier($profile.SID)).Translate([System.Security.Principal.NTAccount]).Value } catch { return "*$($profile.SID)" } } # Restrict the file ACL to the target user, while preserving SYSTEM and the # local Administrators group. Token-bearing config files must not be readable # by other interactive users, but stripping SYSTEM/Administrators would break # OS-level operations (Windows Update, antivirus scans, MDM rollback) that run # as those principals. Uses icacls (always present on supported Windows) and # downgrades failures to a warning so the script does not abort if ACL editing # is blocked by AV/MDM policy. function Restrict-FilePermission { param([string]$Path) if (-not (Test-Path $Path)) { return } try { $user = Get-FileGrantee & icacls $Path /inheritance:r ` /grant:r "${user}:(F)" ` /grant:r "BUILTIN\Administrators:(F)" ` /grant:r "NT AUTHORITY\SYSTEM:(F)" *>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "icacls returned $LASTEXITCODE" } } catch { Log "[WARN] Failed to restrict permissions on ${Path}: $_" } } # Append a trailing newline to a non-empty file whose last byte is not '\n'. # Required before appending a new key to YAML-like configs whose last line may # not be newline-terminated; without it the new key concatenates with the # previous one. function Add-NewlineIfMissing { param([string]$Path) if (-not (Test-Path $Path)) { return } $bytes = [IO.File]::ReadAllBytes($Path) if ($bytes.Length -gt 0 -and $bytes[-1] -ne 0x0A) { [IO.File]::AppendAllText($Path, "`n") } } # --------------------------------------------------------------------------- # Discovery (find existing tokens on disk) # --------------------------------------------------------------------------- # Walk every Guard-managed config path under USER_HOME and return each unique # tg_org_* value found, in discovery order. function Get-ExistingTokens { if ($env:XDG_CONFIG_HOME) { $xdgPnpmRc = Join-Path $env:XDG_CONFIG_HOME "pnpm\rc" } else { $xdgPnpmRc = Join-Path $UserHome "AppData\Local\pnpm\config\rc" } $checkFiles = @( (Join-Path $UserHome ".npmrc"), $xdgPnpmRc, (Join-Path $UserHome ".yarnrc.yml"), (Join-Path $UserHome ".bunfig.toml"), (Join-Path $UserHome "AppData\Roaming\pip\pip.ini"), (Join-Path $UserHome "AppData\Roaming\uv\uv.toml"), (Join-Path $UserHome "AppData\Roaming\pypoetry\auth.toml"), $BundleConfigPath, (Join-Path $UserHome "_netrc"), (Join-Path $UserHome ".netrc"), (Join-Path $ComposerHomeDir "auth.json") ) $tokensFound = @() foreach ($file in $checkFiles) { if (Test-Path $file) { $match = Select-String -Path $file -Pattern 'tg_org_[A-Za-z0-9_-]+' -AllMatches | Select-Object -First 1 if ($match) { $tokensFound += $match.Matches[0].Value } } } $unique = @() foreach ($t in $tokensFound) { if ($unique -notcontains $t) { $unique += $t } } return ,$unique } # --------------------------------------------------------------------------- # Pre-check (does this user have anything configurable?) # --------------------------------------------------------------------------- # Returns $true when at least one tool or pre-existing config file exists # for any of the requested scopes, $false otherwise. function Test-HasTarget { if (Should-ProvisionAll) { return ((Has-Scope "npm") -or (Has-Scope "pypi") -or (Has-Scope "rubygems") -or (Has-Scope "golang") -or (Has-Scope "packagist")) } $found = $false if (Has-Scope "npm") { if ((Test-Path (Join-Path $UserHome ".npmrc")) -or (Get-Command npm -ErrorAction SilentlyContinue)) { $found = $true } if (Find-Command pnpm) { $found = $true } if (Test-Path (Join-Path $UserHome ".yarnrc.yml")) { $found = $true } if (Find-Command yarn) { $found = $true } if (Test-Path (Join-Path $UserHome ".bunfig.toml")) { $found = $true } if (Find-Command bun) { $found = $true } } if (Has-Scope "pypi") { if (Test-Path (Join-Path $UserHome "AppData\Roaming\pip\pip.ini")) { $found = $true } if ((Find-Command pip3) -or (Find-Command pip)) { $found = $true } if (Test-PyHasPip) { $found = $true } if (Test-Path (Join-Path $UserHome "AppData\Roaming\uv\uv.toml")) { $found = $true } if (Find-Command uv) { $found = $true } if (Find-Command poetry) { $found = $true } } if (Has-Scope "rubygems") { if (Test-Path $BundleConfigPath) { $found = $true } if (Find-Command bundle) { $found = $true } if (Find-Command ruby) { $found = $true } } if (Has-Scope "golang") { if (Test-Path $GoEnvPath) { $found = $true } if (Find-Command go) { $found = $true } } if (Has-Scope "packagist") { if (Test-Path (Join-Path $ComposerHomeDir "config.json")) { $found = $true } if (Test-Path (Join-Path $ComposerHomeDir "auth.json")) { $found = $true } if (Find-Command composer) { $found = $true } } return $found } # --------------------------------------------------------------------------- # Write-permission preflight (avoid minting a token we cannot install) # --------------------------------------------------------------------------- # # Before requesting a token or writing anything, check that every config file # this script would write can actually be written. If any cannot, stop without # requesting a token and without partially configuring the machine. # Returns $true if the config file at $Path can be created or updated by this # process. An existing file is probed by opening it for write (without # truncating); a missing file by trial-creating a uniquely named file in the # nearest existing ancestor directory and removing it. A trial write is used # rather than reading ACLs because it reflects the real effective permission; # the handle is closed before the file is removed so nothing is left behind. function Test-WritableTarget { param([string]$Path) if (Test-Path -LiteralPath $Path) { try { $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) $fs.Close(); $fs.Dispose() return $true } catch { return $false } } # Missing file: walk up to the nearest EXISTING path component. $dir = Split-Path -Parent $Path while ($dir -and -not (Test-Path -LiteralPath $dir)) { $dir = Split-Path -Parent $dir } # The nearest existing ancestor must be a directory. If it exists but is a # regular file, the real directory creation would fail regardless of perms. if (-not $dir -or -not (Test-Path -LiteralPath $dir -PathType Container)) { return $false } $tmp = Join-Path $dir (".tg-preflight." + [System.IO.Path]::GetRandomFileName()) try { $fs = [System.IO.File]::Create($tmp) $fs.Close(); $fs.Dispose() Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue return $true } catch { return $false } } # Returns an array of "|" strings for every config file that would be # written for the requested scopes and direct-write mode but is not writable. function Get-UnwritableTargets { $bad = @() $check = { param($Pm, $TargetPath) if (-not (Test-WritableTarget $TargetPath)) { $script:_unwritable += "$Pm|$TargetPath" } } $script:_unwritable = @() if (Has-Scope "npm") { $npmrc = Join-Path $UserHome ".npmrc" if ((Test-Path $npmrc) -or (Get-Command npm -ErrorAction SilentlyContinue) -or (Find-Command pnpm) -or (Should-ProvisionAll)) { & $check "npm" $npmrc } if ($env:XDG_CONFIG_HOME) { $pnpmRc = Join-Path $env:XDG_CONFIG_HOME "pnpm\rc" } else { $pnpmRc = Join-Path $UserHome "AppData\Local\pnpm\config\rc" } if (Test-Path $pnpmRc) { & $check "pnpm" $pnpmRc } $yarnrc = Join-Path $UserHome ".yarnrc.yml" if ((Test-Path $yarnrc) -or (Find-Command yarn) -or (Should-ProvisionAll)) { & $check "yarn" $yarnrc } $bunfig = Join-Path $UserHome ".bunfig.toml" if ((Test-Path $bunfig) -or (Find-Command bun) -or (Should-ProvisionAll)) { & $check "bun" $bunfig } } if (Has-Scope "pypi") { $pipConf = Join-Path $UserHome "AppData\Roaming\pip\pip.ini" if ((Test-Path $pipConf) -or (Find-Command pip3) -or (Find-Command pip) -or (Test-PyHasPip) -or (Should-ProvisionAll)) { & $check "pip" $pipConf } $uvConf = Join-Path $UserHome "AppData\Roaming\uv\uv.toml" if ((Test-Path $uvConf) -or (Find-Command uv) -or (Should-ProvisionAll)) { & $check "uv" $uvConf } if ((Find-Command poetry) -or (Should-ProvisionAll)) { & $check "poetry" (Join-Path $UserHome "AppData\Roaming\pypoetry\auth.toml") } } if (Has-Scope "rubygems") { if ((Test-Path $BundleConfigPath) -or (Find-Command bundle) -or (Find-Command ruby) -or (Should-ProvisionAll)) { & $check "bundler" $BundleConfigPath } } if (Has-Scope "golang") { $goDir = Find-CommandDir "go" if ((Test-Path $GoEnvPath) -or $goDir -or (Should-ProvisionAll)) { if ($env:GOENV -ne "off") { & $check "go" $GoEnvPath } & $check "go" $NetrcPath } } if (Has-Scope "packagist") { $composerDir = Find-CommandDir "composer" $composerCfg = Join-Path $ComposerHomeDir "config.json" $composerAuth = Join-Path $ComposerHomeDir "auth.json" if ((Test-Path $composerCfg) -or (Test-Path $composerAuth) -or $composerDir -or (Should-ProvisionAll)) { & $check "composer" $composerCfg & $check "composer" $composerAuth } } $result = $script:_unwritable Remove-Variable -Name _unwritable -Scope script -ErrorAction SilentlyContinue return ,$result } # Fail-closed gate. Logs a diagnostic line per unwritable target (which package # manager, which path) and returns $false when any prospective target is not # writable; $true when all are writable. Run before any mint or write. function Test-PreflightWritable { $bad = Get-UnwritableTargets if (-not $bad -or $bad.Count -eq 0) { return $true } foreach ($entry in $bad) { $parts = $entry -split '\|', 2 if (Test-Path -LiteralPath $parts[1] -PathType Container) { Log "[Error] config not writable: $($parts[0]) ($($parts[1])) -- path is a directory, not a file; remove it and re-run (a container volume mount of a missing file creates an empty directory)" } else { Log "[Error] config not writable: $($parts[0]) ($($parts[1])) -- fix permissions and re-run" } } return $false } # --------------------------------------------------------------------------- # Backup and rollback # --------------------------------------------------------------------------- $Timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $TmpBackupDir = Join-Path ([System.IO.Path]::GetTempPath()) "guard-backup-$Timestamp" $script:TmpBackupFiles = @() $script:CreatedFiles = @() function Backup-File { param([string]$Path) if (Test-Path $Path) { # Persistent backup for manual rollback $dir = Split-Path $Path -Parent $name = Split-Path $Path -Leaf $backupPath = Join-Path $dir "$name-backup-$Timestamp" Copy-Item $Path $backupPath # Copy-Item preserves the source's ACL. A token-bearing source whose # ACL allowed other users to read it would leak the token through the # backup. Lock the backup down to the same SYSTEM/Administrators/user # set as live config files written by Restrict-FilePermission. Restrict-FilePermission $backupPath Log "[Backup] Created $backupPath" # Temporary backup for auto-rollback. Use the array index as the # filename to avoid leaf-name collisions across different directories # (e.g. ~/.bundle/config and ~/.config/foo/config both leaf "config"). $idx = $script:TmpBackupFiles.Count Copy-Item $Path (Join-Path $TmpBackupDir "$idx") $script:TmpBackupFiles += $Path } } function Track-CreatedFile { param([string]$Path) $script:CreatedFiles += $Path } # Back up a file that has already been rewritten in place (e.g. by # `composer config`), using a snapshot of the original taken before the # rewrite, so a rollback can restore it. function Register-ComposerBackup { param([string]$Live, [string]$Original) $dir = Split-Path $Live -Parent $name = Split-Path $Live -Leaf $backupPath = Join-Path $dir "$name-backup-$Timestamp" Copy-Item $Original $backupPath Restrict-FilePermission $backupPath Log "[Backup] Created $backupPath" $idx = $script:TmpBackupFiles.Count Copy-Item $Original (Join-Path $TmpBackupDir "$idx") $script:TmpBackupFiles += $Live } function Invoke-Rollback { # No-op if nothing has been tracked yet, so callers can invoke # Invoke-Rollback unconditionally without a misleading log line. if ($script:TmpBackupFiles.Count -eq 0 -and $script:CreatedFiles.Count -eq 0) { return } Log "[Error] setup.ps1 failed. Rolling back changes..." for ($i = 0; $i -lt $script:TmpBackupFiles.Count; $i++) { $src = $script:TmpBackupFiles[$i] $backupFile = Join-Path $TmpBackupDir "$i" if (Test-Path $backupFile) { Copy-Item $backupFile $src -Force Log "[Rollback] Restored $src" } } foreach ($src in $script:CreatedFiles) { if (Test-Path $src) { Remove-Item $src -Force Log "[Rollback] Removed $src" } } Remove-Item $TmpBackupDir -Recurse -Force -ErrorAction SilentlyContinue } function Invoke-Cleanup { Remove-Item $TmpBackupDir -Recurse -Force -ErrorAction SilentlyContinue } # Initialise the temp-backup directory only when a write path will run # (legacy mode or `install` subcommand). The read-only primitives don't need # it and avoiding the mkdir keeps them side-effect-free. function Initialize-BackupDir { New-Item -ItemType Directory -Path $TmpBackupDir -Force | Out-Null } # --------------------------------------------------------------------------- # Install (write config files for one resolved token) # --------------------------------------------------------------------------- # Replace or append a single TOML section. Preserves all other sections in # the file. Used for poetry's flat-section config layout; not a general TOML # editor. function Set-PoetryTomlSection { param([string]$Path, [string]$Section, [string[]]$Lines) if (Test-Path $Path) { $existing = Get-Content $Path } else { $existing = @() } $kept = @() $inTarget = $false foreach ($line in $existing) { if ($line -match '^\s*\[\s*([^\]]+?)\s*\]\s*$') { $inTarget = ($matches[1] -eq $Section) if (-not $inTarget) { $kept += $line } } elseif (-not $inTarget) { $kept += $line } } if ($kept.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($kept[-1])) { $kept += "" } $kept += "[$Section]" foreach ($l in $Lines) { $kept += $l } # Write as BOM-less UTF-8: poetry rejects a file that starts with a BOM. [IO.File]::WriteAllLines($Path, $kept, [System.Text.UTF8Encoding]::new($false)) } # Replace an existing key in-place, or append it (with a trailing-newline guard). function Set-BundleKey { param([string]$Path, [string]$Key, [string]$Value) # [regex]::Escape the key for regex use so `/`, `.`, and any future # special characters are matched literally. $keyRegex = [regex]::Escape($Key) if (Select-String -Path $Path -Pattern "^${keyRegex}:" -Quiet) { (Get-Content $Path) ` -replace "^${keyRegex}:.*", "${Key}: `"$Value`"" | Set-Content $Path } else { # Existing files may lack a trailing newline (Add-Content does not # insert a leading newline). Without this guard the new key runs into # the previous line and breaks YAML parsing in Bundler. Add-NewlineIfMissing $Path Add-Content -Path $Path -Value "${Key}: `"$Value`"" } } # --- Composer JSON helpers --------------------------------------------------- # # Composer's JSON files are merged natively via ConvertFrom-Json / # ConvertTo-Json. Malformed / non-object JSON aborts the whole run (rolls back # every scope, exits non-zero) rather than corrupting the file or silently # leaving Composer pointed at the public registry. # Abort from inside the Composer block: roll back every scope written so far # and exit non-zero. $1 = offending file, $2 = reason. function Invoke-ComposerAbort { param([string]$Path, [string]$Reason) Log "[Error] $Path $Reason." Log "[Error] Refusing to corrupt it or to leave Composer routed at the public registry (packagist.org)." Log "[Error] Fix or remove the file, or configure Composer manually per the Takumi Guard admin deployment guide, then re-run." Invoke-Rollback exit 1 } # Read an existing Composer JSON file into a PSCustomObject. Returns an empty # object when the file is absent/blank; aborts when it is present but malformed # or not a JSON object (we must never overwrite a file we cannot understand). function Read-ComposerJsonOrAbort { param([string]$Path) if (-not (Test-Path $Path)) { return [PSCustomObject]@{} } $raw = Get-Content $Path -Raw -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($raw)) { return [PSCustomObject]@{} } try { $obj = $raw | ConvertFrom-Json -ErrorAction Stop } catch { Invoke-ComposerAbort $Path "contains malformed JSON and cannot be safely merged" } if (($null -eq $obj) -or -not ($obj -is [System.Management.Automation.PSCustomObject])) { Invoke-ComposerAbort $Path "is not a JSON object and cannot be safely merged" } return $obj } # Write a PSCustomObject back as JSON. -Depth 32 keeps deeply-nested existing # config from being silently truncated (ConvertTo-Json defaults to depth 2). # The file is written as BOM-less UTF-8. function Write-ComposerJson { param([string]$Path, $Json) $out = $Json | ConvertTo-Json -Depth 32 [IO.File]::WriteAllText($Path, $out + "`n", [System.Text.UTF8Encoding]::new($false)) } # Ensure config.json has a "takumi-guard" composer repository AND # "packagist.org": false, preserving the user's other repositories and config. # Composer accepts both the object form ({"name": {...}}) and the list form # ([{...}]); whichever the file already uses is preserved, and a stale # takumi-guard / packagist.org entry is replaced so a re-run is stable. function Set-ComposerConfigJson { param([string]$Path, [string]$Url) $json = Read-ComposerJsonOrAbort $Path if ((-not ($json.PSObject.Properties.Name -contains 'repositories')) -or ($null -eq $json.repositories)) { $json | Add-Member -NotePropertyName 'repositories' -NotePropertyValue ([PSCustomObject]@{}) -Force } if ($json.repositories -is [System.Array]) { # List form: drop any stale takumi-guard / packagist.org entry, append ours. $kept = @() foreach ($e in @($json.repositories)) { $names = @($e.PSObject.Properties.Name) if ($names -contains 'packagist.org') { continue } if (($names -contains 'name') -and ($e.name -eq 'takumi-guard')) { continue } $kept += $e } $kept += [PSCustomObject]@{ 'packagist.org' = $false } $kept += [PSCustomObject]@{ name = 'takumi-guard'; type = 'composer'; url = $Url } $json.repositories = $kept } else { # Object form. $json.repositories | Add-Member -NotePropertyName 'takumi-guard' -NotePropertyValue ([PSCustomObject]@{ type = 'composer'; url = $Url }) -Force $json.repositories | Add-Member -NotePropertyName 'packagist.org' -NotePropertyValue $false -Force } Write-ComposerJson $Path $json } # Ensure auth.json has the http-basic credential for the proxy host, # preserving any other hosts / auth methods already present. # # Every top-level value in Composer's auth schema is an object -- Composer # seeds the file with one empty object per auth method -- so a top-level empty # list is not a valid value and is normalized back to an empty object here. A # file that already holds only objects is left unchanged. function Set-ComposerAuthJson { param([string]$Path, [string]$ComposerHost, [string]$Token) $json = Read-ComposerJsonOrAbort $Path foreach ($p in @($json.PSObject.Properties)) { if (($p.Value -is [System.Array]) -and (@($p.Value).Count -eq 0)) { $json | Add-Member -NotePropertyName $p.Name -NotePropertyValue ([PSCustomObject]@{}) -Force } } if ((-not ($json.PSObject.Properties.Name -contains 'http-basic')) -or ($null -eq $json.'http-basic') -or ($json.'http-basic' -is [System.Array])) { $json | Add-Member -NotePropertyName 'http-basic' -NotePropertyValue ([PSCustomObject]@{}) -Force } $json.'http-basic' | Add-Member -NotePropertyName $ComposerHost -NotePropertyValue ([PSCustomObject]@{ username = 'token'; password = $Token }) -Force Write-ComposerJson $Path $json } function Install-Configs { param([string]$Token) # ---- npm ecosystem ---- if (Has-Scope "npm") { # --- npm / pnpm (.npmrc) --- $npmrc = Join-Path $UserHome ".npmrc" if ((Test-Path $npmrc) -or (Get-Command npm -ErrorAction SilentlyContinue) -or (Find-Command pnpm) -or (Should-ProvisionAll)) { if ((Test-Path $npmrc) -and (Select-String -Path $npmrc -Pattern $Token -SimpleMatch -Quiet)) { Log "[OK] npm already configured" } else { if (-not (Test-Path $npmrc)) { Track-CreatedFile $npmrc } Backup-File $npmrc $npmCliSucceeded = $false if ((Should-UseCli) -and (Get-Command npm -ErrorAction SilentlyContinue)) { # npm is a native command; under $ErrorActionPreference = # "Stop" its benign stderr (e.g. a config warning) would # abort the script, so run it with "Continue" and judge # success from $LASTEXITCODE. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $existingRegistry = & npm config get registry --userconfig $npmrc 2>$null if ($existingRegistry -and $existingRegistry -ne "https://npm.flatt.tech/" -and $existingRegistry -ne "https://registry.npmjs.org/" -and $existingRegistry -ne "undefined") { Log "[WARN] Existing npm registry will be overwritten: $existingRegistry" } & npm config set "//npm.flatt.tech/:_authToken" $Token --userconfig $npmrc 2>$null $rc1 = $LASTEXITCODE & npm config set registry "https://npm.flatt.tech/" --userconfig $npmrc 2>$null if ($rc1 -eq 0 -and $LASTEXITCODE -eq 0) { $npmCliSucceeded = $true } } catch { $npmCliSucceeded = $false } finally { $ErrorActionPreference = $prevEap } } if (-not $npmCliSucceeded) { # Direct edit when npm is absent, its CLI failed, or direct-write mode. Set-NpmrcToken $npmrc $Token } Restrict-FilePermission $npmrc Log "[OK] npm configured" } # end tg_org_ check } else { Log "[SKIP] npm not available" } # --- pnpm (global rc) --- # pnpm is configured through .npmrc above. An existing pnpm global rc # is updated with the token too; it is not created. if ($env:XDG_CONFIG_HOME) { $pnpmRcDir = Join-Path $env:XDG_CONFIG_HOME "pnpm" } else { $pnpmRcDir = Join-Path $UserHome "AppData\Local\pnpm\config" } $pnpmRc = Join-Path $pnpmRcDir "rc" if (Test-Path $pnpmRc) { if (Select-String -Path $pnpmRc -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] pnpm already configured" } else { Backup-File $pnpmRc Set-NpmrcToken $pnpmRc $Token Restrict-FilePermission $pnpmRc Log "[OK] pnpm configured" } } # --- yarn v2+ (.yarnrc.yml) --- # A file that holds the token in any other form is rewritten. $yarnrc = Join-Path $UserHome ".yarnrc.yml" if (Test-Path $yarnrc) { if ((Select-String -Path $yarnrc -Pattern $Token -SimpleMatch -Quiet) -and (Test-YarnrcCurrent $yarnrc $Token)) { Log "[OK] yarn already configured" } else { Backup-File $yarnrc Set-YarnrcToken $yarnrc $Token Restrict-FilePermission $yarnrc Log "[OK] yarn configured" } # end tg_org_ check } elseif ((Find-Command yarn) -or (Should-ProvisionAll)) { Set-YarnrcToken $yarnrc $Token Track-CreatedFile $yarnrc Restrict-FilePermission $yarnrc Log "[OK] yarn configured" } else { Log "[SKIP] yarn not available" } # --- bun (.bunfig.toml) --- $bunfig = Join-Path $UserHome ".bunfig.toml" if (Test-Path $bunfig) { if (Select-String -Path $bunfig -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] bun already configured" } else { Backup-File $bunfig # Detect the registry inline-table line itself (anchored), not a # bare host substring that could match a comment with no token=. if (Select-String -Path $bunfig -Pattern '^registry\s*=\s*.*npm\.flatt\.tech' -Quiet) { (Get-Content $bunfig) -replace '^registry\s*=\s*.*npm\.flatt\.tech.*', "registry = { url = `"https://npm.flatt.tech/`", token = `"$Token`" }" | Set-Content $bunfig } else { if (Select-String -Path $bunfig -Pattern '^\[install\]' -Quiet) { $content = Get-Content $bunfig $newContent = @() foreach ($line in $content) { $newContent += $line if ($line -match '^\[install\]') { $newContent += "registry = { url = `"https://npm.flatt.tech/`", token = `"$Token`" }" } } $newContent | Set-Content $bunfig } else { Add-Content -Path $bunfig -Value "" Add-Content -Path $bunfig -Value "[install]" Add-Content -Path $bunfig -Value "registry = { url = `"https://npm.flatt.tech/`", token = `"$Token`" }" } } Restrict-FilePermission $bunfig Log "[OK] bun configured" } # end tg_org_ check } elseif ((Find-Command bun) -or (Should-ProvisionAll)) { Set-Content -Path $bunfig -Value "[install]" Add-Content -Path $bunfig -Value "registry = { url = `"https://npm.flatt.tech/`", token = `"$Token`" }" Track-CreatedFile $bunfig Restrict-FilePermission $bunfig Log "[OK] bun configured" } else { Log "[SKIP] bun not available" } } # Has-Scope npm # ---- PyPI ecosystem ---- if (Has-Scope "pypi") { # --- pip (pip.ini on Windows) --- $pipDir = Join-Path $UserHome "AppData\Roaming\pip" $pipConf = Join-Path $pipDir "pip.ini" if (Test-Path $pipConf) { if (Select-String -Path $pipConf -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] pip already configured" } else { Backup-File $pipConf # Detect the index-url line itself (anchored), not a bare host # substring that could match a trusted-host line or a comment. if (Select-String -Path $pipConf -Pattern '^index-url\s*=\s*.*pypi\.flatt\.tech' -Quiet) { (Get-Content $pipConf) -replace '^index-url\s*=\s*.*pypi\.flatt\.tech.*', "index-url = https://token:$Token@pypi.flatt.tech/simple/" | Set-Content $pipConf } else { if (Select-String -Path $pipConf -Pattern '^\[global\]' -Quiet) { $content = Get-Content $pipConf $newContent = @() foreach ($line in $content) { $newContent += $line if ($line -match '^\[global\]') { $newContent += "index-url = https://token:$Token@pypi.flatt.tech/simple/" } } $newContent | Set-Content $pipConf } else { Add-Content -Path $pipConf -Value "" Add-Content -Path $pipConf -Value "[global]" Add-Content -Path $pipConf -Value "index-url = https://token:$Token@pypi.flatt.tech/simple/" } } Restrict-FilePermission $pipConf Log "[OK] pip configured" } # end tg_org_ check } elseif ((Find-Command pip3) -or (Find-Command pip) -or (Test-PyHasPip) -or (Should-ProvisionAll)) { if (-not (Test-Path $pipDir)) { New-Item -ItemType Directory -Path $pipDir -Force | Out-Null } Set-Content -Path $pipConf -Value "[global]" Add-Content -Path $pipConf -Value "index-url = https://token:$Token@pypi.flatt.tech/simple/" Track-CreatedFile $pipConf Restrict-FilePermission $pipConf Log "[OK] pip configured" } else { Log "[SKIP] pip not available" } # --- uv --- $uvDir = Join-Path $UserHome "AppData\Roaming\uv" $uvConf = Join-Path $uvDir "uv.toml" if (Test-Path $uvConf) { if (Select-String -Path $uvConf -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] uv already configured" } else { Backup-File $uvConf # Detect the index url line itself (anchored), not a bare host # substring that could match a comment. if (Select-String -Path $uvConf -Pattern '^url\s*=\s*".*pypi\.flatt\.tech' -Quiet) { (Get-Content $uvConf) -replace '^url\s*=\s*".*pypi\.flatt\.tech.*"', "url = `"https://token:$Token@pypi.flatt.tech/simple/`"" | Set-Content $uvConf } else { if (Select-String -Path $uvConf -Pattern 'default = true' -Quiet) { (Get-Content $uvConf) -replace 'default = true', 'default = false' | Set-Content $uvConf } Add-Content -Path $uvConf -Value "" Add-Content -Path $uvConf -Value "[[index]]" Add-Content -Path $uvConf -Value "url = `"https://token:$Token@pypi.flatt.tech/simple/`"" Add-Content -Path $uvConf -Value "default = true" } Restrict-FilePermission $uvConf Log "[OK] uv configured" } # end tg_org_ check } elseif ((Find-Command uv) -or (Should-ProvisionAll)) { if (-not (Test-Path $uvDir)) { New-Item -ItemType Directory -Path $uvDir -Force | Out-Null } Set-Content -Path $uvConf -Value "[[index]]" Add-Content -Path $uvConf -Value "url = `"https://token:$Token@pypi.flatt.tech/simple/`"" Add-Content -Path $uvConf -Value "default = true" Track-CreatedFile $uvConf Restrict-FilePermission $uvConf Log "[OK] uv configured" } else { Log "[SKIP] uv not available" } # --- poetry --- # # auth.toml is written directly rather than with `poetry config`: when # the script runs as SYSTEM, `poetry config` writes to the SYSTEM # profile instead of the target user's. if ((Find-Command poetry) -or (Should-ProvisionAll)) { $poetryConfigDir = Join-Path $UserHome "AppData\Roaming\pypoetry" $poetryAuth = Join-Path $poetryConfigDir "auth.toml" if ((Test-Path $poetryAuth) -and (Select-String -Path $poetryAuth -Pattern $Token -SimpleMatch -Quiet)) { Log "[OK] poetry already configured" } else { # Poetry cannot configure a package source globally -- `poetry config # repositories.*` only sets a *publish* target, not an install source. # The install source must be added per-project via `poetry source add` # in pyproject.toml. This script can only pre-place credentials in # auth.toml so that authentication works once the user adds the source. if (-not (Test-Path $poetryConfigDir)) { New-Item -ItemType Directory -Path $poetryConfigDir -Force | Out-Null } if (Test-Path $poetryAuth) { Backup-File $poetryAuth } else { Track-CreatedFile $poetryAuth } Set-PoetryTomlSection -Path $poetryAuth -Section "http-basic.takumi-guard" -Lines @( 'username = "token"', "password = `"$Token`"" ) Restrict-FilePermission $poetryAuth Log "[OK] poetry configured" } } else { Log "[SKIP] poetry not available" } } # Has-Scope pypi # ---- RubyGems ecosystem ---- if (Has-Scope "rubygems") { # --- Bundler (~/.bundle/config) --- # Config file path resolution: # BUNDLE_USER_CONFIG > BUNDLE_USER_HOME\config > $UserHome\.bundle\config # Bundler does NOT follow XDG_CONFIG_HOME. # Key format: BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/ (trailing slash required). # File format is identical between Bundler 1.17.2 and 2.x. # # Mirror URL and credentials are written as two separate keys # (BUNDLE_MIRROR__... + BUNDLE_) so # the token does not appear in `bundle env` output or in Bundler error # messages that echo back the mirror URL. Both keys are required for # Bundler to authenticate the mirrored fetch; supported on Bundler >=1.13. $bundleConfig = $BundleConfigPath $bundleDir = Split-Path $bundleConfig -Parent $bundleKey = 'BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/' $bundleValue = 'https://rubygems.flatt.tech/' $bundleCredKey = 'BUNDLE_RUBYGEMS__FLATT__TECH' $bundleCredValue = "token:$Token" if (Test-Path $bundleConfig) { if (Select-String -Path $bundleConfig -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] bundler already configured" } else { Backup-File $bundleConfig Set-BundleKey -Path $bundleConfig -Key $bundleKey -Value $bundleValue Set-BundleKey -Path $bundleConfig -Key $bundleCredKey -Value $bundleCredValue Restrict-FilePermission $bundleConfig Log "[OK] bundler configured" } } elseif ((Find-Command bundle) -or (Find-Command ruby) -or (Should-ProvisionAll)) { if (-not (Test-Path $bundleDir)) { New-Item -ItemType Directory -Path $bundleDir -Force | Out-Null } Track-CreatedFile $bundleConfig Set-Content -Path $bundleConfig -Value "---" Add-Content -Path $bundleConfig -Value "${bundleKey}: `"$bundleValue`"" Add-Content -Path $bundleConfig -Value "${bundleCredKey}: `"$bundleCredValue`"" Restrict-FilePermission $bundleConfig Log "[OK] bundler configured" } else { Log "[SKIP] bundler not available" } } # Has-Scope rubygems # ---- Go ecosystem ---- # # Go needs two separate files, unlike the single-rc-file ecosystems # above: # 1. GOPROXY in the go env file (resolved by Get-GoEnvPath) -- the # registry endpoint. # 2. A credential line in %USERPROFILE%\_netrc -- the go toolchain # authenticates to GOPROXY servers via .netrc HTTP Basic auth. # The login field is ignored by the toolchain; the tg_org_* # token goes in the password field. # # GOPROXY is set to the bare URL with NO ,direct / |direct fallback. # A fallback lets the toolchain fetch directly from VCS when the # proxy returns an error, which silently bypasses Takumi Guard for # any module that is not yet indexed (404) or is actively blocked # (403, with |direct). if (Has-Scope "golang") { $goDir = Find-CommandDir "go" $goProxyUrl = "https://golang.flatt.tech" $goRegistryHost = "golang.flatt.tech" if ((Test-Path $GoEnvPath) -or $goDir -or (Should-ProvisionAll)) { # --- GOPROXY (go env file) --- if ($env:GOENV -eq "off") { # GOENV=off disables the go env file entirely; `go env -w` # would error and there is no file to edit. The _netrc # credential is still written below. Log "[SKIP] go GOPROXY not configured (GOENV=off disables the go env file)" } elseif ((Test-Path $GoEnvPath) -and ((Get-Content $GoEnvPath -ErrorAction SilentlyContinue) -contains "GOPROXY=$goProxyUrl")) { Log "[OK] go already configured (GOPROXY)" } else { $goEnvDir = Split-Path -Parent $GoEnvPath if (-not (Test-Path $goEnvDir)) { New-Item -ItemType Directory -Path $goEnvDir -Force | Out-Null } if (Test-Path $GoEnvPath) { Backup-File $GoEnvPath } else { Track-CreatedFile $GoEnvPath } # `go env -w` rewrites only the GOPROXY key, preserving # every other setting. APPDATA override at script init # already routes GOENV under USER_HOME. Fall through to # direct edit when the CLI is absent or fails. $goEnvWSucceeded = $false if ((Should-UseCli) -and $goDir) { # `go env -w` is a native command; run it with # $ErrorActionPreference='Continue' so benign toolchain # stderr on a cold cache cannot abort the install path. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { & (Join-Path $goDir "go.exe") env -w "GOPROXY=$goProxyUrl" 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { $goEnvWSucceeded = $true } } catch { $goEnvWSucceeded = $false } finally { $ErrorActionPreference = $prevEap } } if (-not $goEnvWSucceeded) { if ((Test-Path $GoEnvPath) -and ((Select-String -Path $GoEnvPath -Pattern '^GOPROXY=' -Quiet))) { $content = Get-Content $GoEnvPath $updated = $content | ForEach-Object { if ($_ -match '^GOPROXY=') { "GOPROXY=$goProxyUrl" } else { $_ } } Set-Content -Path $GoEnvPath -Value $updated } else { Add-NewlineIfMissing $GoEnvPath Add-Content -Path $GoEnvPath -Value "GOPROXY=$goProxyUrl" } } Restrict-FilePermission $GoEnvPath Log "[OK] go configured (GOPROXY)" } # --- credential (_netrc) --- if ((Test-Path $NetrcPath) -and (Select-String -Path $NetrcPath -Pattern $Token -SimpleMatch -Quiet)) { Log "[OK] go already configured (_netrc)" } else { if (Test-Path $NetrcPath) { Backup-File $NetrcPath } else { Track-CreatedFile $NetrcPath } $netrcLine = "machine $goRegistryHost login token password $Token" # Detect an existing entry by machine name (not by token # value) so a stale credential for this host is replaced # in place. Only the matching single-line entry is # rewritten; every other machine block (git, etc.) is # left untouched. $machinePattern = '^\s*machine\s+golang\.flatt\.tech(\s|$)' if ((Test-Path $NetrcPath) -and (Select-String -Path $NetrcPath -Pattern $machinePattern -Quiet)) { $content = Get-Content $NetrcPath $updated = $content | ForEach-Object { if ($_ -match $machinePattern) { $netrcLine } else { $_ } } Set-Content -Path $NetrcPath -Value $updated } else { Add-NewlineIfMissing $NetrcPath Add-Content -Path $NetrcPath -Value $netrcLine } Restrict-FilePermission $NetrcPath Log "[OK] go configured (_netrc)" } } else { Log "[SKIP] go not available" } } # Has-Scope golang # ---- Composer (Packagist) ecosystem ---- # # Composer is configured via two JSON files under COMPOSER_HOME: # 1. config.json -- the "takumi-guard" composer repository plus # "packagist.org": false. Disabling packagist.org is required; otherwise # Composer resolves from the public registry and bypasses Takumi Guard. # 2. auth.json -- an http-basic credential for packagist.flatt.tech (the # username is ignored; the token is the password). # # When composer is available and direct-write is off, the official # `composer config` CLI writes both files. Otherwise PowerShell writes them # natively (ConvertFrom/ConvertTo-Json). A pre-existing malformed file # aborts the run rather than being corrupted. if (Has-Scope "packagist") { $composerDir = Find-CommandDir "composer" $composerCfg = Join-Path $ComposerHomeDir "config.json" $composerAuth = Join-Path $ComposerHomeDir "auth.json" $composerUrl = "https://packagist.flatt.tech" $composerHostName = "packagist.flatt.tech" # An auth.json holding a top-level empty list counts as unconfigured, # so the merge below runs and normalizes it; the credential check alone # would treat the file as done and leave it rejected by Composer's # schema. # # Only top-level values are examined, matching what the merge # normalizes: a nested empty list belongs to a key this script never # writes, and rewriting for it would touch the file and add a backup on # every run. A malformed file reports nothing to do; the credential path # below decides what happens to it. $composerAuthNeedsRepair = $false if (Test-Path $composerAuth) { try { $authProbe = (Get-Content $composerAuth -Raw -ErrorAction Stop) | ConvertFrom-Json -ErrorAction Stop if ($authProbe -is [System.Management.Automation.PSCustomObject]) { foreach ($p in @($authProbe.PSObject.Properties)) { if (($p.Value -is [System.Array]) -and (@($p.Value).Count -eq 0)) { $composerAuthNeedsRepair = $true break } } } } catch {} } if ((Test-Path $composerCfg) -or (Test-Path $composerAuth) -or $composerDir -or (Should-ProvisionAll)) { if (-not (Test-Path $ComposerHomeDir)) { New-Item -ItemType Directory -Path $ComposerHomeDir -Force | Out-Null } $composerCliDone = $false # Prefer the official `composer config` CLI when composer is # available and direct-write mode is off. COMPOSER_HOME targets the # resolved per-user config dir and COMPOSER_ALLOW_SUPERUSER lets it # run unattended. Files that are already configured are left # unchanged. If composer fails, any files it touched are restored # and the native-JSON writes below take over. $composerAlreadyOurs = $false if ((Test-Path $composerCfg) -and (Test-Path $composerAuth)) { $cfgRaw0 = Get-Content $composerCfg -Raw -ErrorAction SilentlyContinue if ($cfgRaw0 -and ($cfgRaw0 -match 'packagist\.flatt\.tech') -and ($cfgRaw0 -match '"packagist\.org"') -and (Select-String -Path $composerAuth -Pattern $Token -SimpleMatch -Quiet)) { $composerAlreadyOurs = $true } } # `composer config` edits auth.json textually and leaves an invalid # top-level empty list as it is, so such a file skips the CLI and is # written by the native merge below, which normalizes it. if ((Should-UseCli) -and $composerDir -and (-not $composerAlreadyOurs) -and (-not $composerAuthNeedsRepair)) { $cfgPre = Test-Path $composerCfg $authPre = Test-Path $composerAuth $snapDir = Join-Path $TmpBackupDir ("composer-cli-snap-" + [guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $snapDir -Force | Out-Null if ($cfgPre) { Copy-Item $composerCfg (Join-Path $snapDir "cfg") } if ($authPre) { Copy-Item $composerAuth (Join-Path $snapDir "auth") } $composerExe = Join-Path $composerDir "composer.bat" if (-not (Test-Path $composerExe)) { $composerExe = Join-Path $composerDir "composer" } # composer is a native command; under $ErrorActionPreference = # "Stop" its benign stderr would abort the script, so run it with # "Continue" and judge success from $LASTEXITCODE. $prevEap = $ErrorActionPreference $prevCH = $env:COMPOSER_HOME; $prevAS = $env:COMPOSER_ALLOW_SUPERUSER; $prevNI = $env:COMPOSER_NO_INTERACTION $ErrorActionPreference = 'Continue' $cliOk = $false try { $env:COMPOSER_HOME = $ComposerHomeDir $env:COMPOSER_ALLOW_SUPERUSER = '1' $env:COMPOSER_NO_INTERACTION = '1' & $composerExe config --global repositories.takumi-guard composer $composerUrl 2>$null | Out-Null $ok1 = ($LASTEXITCODE -eq 0) & $composerExe config --global repositories.packagist.org false 2>$null | Out-Null $ok2 = ($LASTEXITCODE -eq 0) & $composerExe config --global --auth "http-basic.$composerHostName" token $Token 2>$null | Out-Null $ok3 = ($LASTEXITCODE -eq 0) if ($ok1 -and $ok2 -and $ok3 -and (Test-Path $composerCfg) -and (Select-String -Path $composerCfg -Pattern 'packagist\.flatt\.tech' -Quiet) -and (Select-String -Path $composerCfg -Pattern '"packagist.org"' -SimpleMatch -Quiet) -and (Test-Path $composerAuth) -and (Select-String -Path $composerAuth -Pattern $Token -SimpleMatch -Quiet)) { $cliOk = $true } } catch { $cliOk = $false } finally { $ErrorActionPreference = $prevEap $env:COMPOSER_HOME = $prevCH; $env:COMPOSER_ALLOW_SUPERUSER = $prevAS; $env:COMPOSER_NO_INTERACTION = $prevNI } if ($cliOk) { # composer wrote the files: back up any originals for # rollback, or track newly created files so rollback removes them. if ($cfgPre) { Register-ComposerBackup $composerCfg (Join-Path $snapDir "cfg") } else { Track-CreatedFile $composerCfg } if ($authPre) { Register-ComposerBackup $composerAuth (Join-Path $snapDir "auth") } else { Track-CreatedFile $composerAuth } Restrict-FilePermission $composerCfg Restrict-FilePermission $composerAuth Log "[OK] composer configured (repositories)" Log "[OK] composer configured (auth)" $composerCliDone = $true } else { # composer failed: restore the originals so the native-JSON # writes below run on a clean file. if ($cfgPre) { Copy-Item (Join-Path $snapDir "cfg") $composerCfg -Force } elseif (Test-Path $composerCfg) { Remove-Item $composerCfg -Force } if ($authPre) { Copy-Item (Join-Path $snapDir "auth") $composerAuth -Force } elseif (Test-Path $composerAuth) { Remove-Item $composerAuth -Force } } Remove-Item $snapDir -Recurse -Force -ErrorAction SilentlyContinue } if (-not $composerCliDone) { # --- repositories (config.json) --- # Idempotency needs BOTH the proxy host and the packagist.org # disable present; the host (not the full URL) is matched so # escaped slashes do not affect detection. $cfgConfigured = $false if (Test-Path $composerCfg) { $cfgRaw = Get-Content $composerCfg -Raw -ErrorAction SilentlyContinue if ($cfgRaw -and ($cfgRaw -match 'packagist\.flatt\.tech') -and ($cfgRaw -match '"packagist\.org"')) { $cfgConfigured = $true } } if ($cfgConfigured) { Log "[OK] composer already configured (repositories)" } else { if (Test-Path $composerCfg) { Backup-File $composerCfg } else { Track-CreatedFile $composerCfg } Set-ComposerConfigJson -Path $composerCfg -Url $composerUrl Restrict-FilePermission $composerCfg Log "[OK] composer configured (repositories)" } # --- credential (auth.json) --- if ((Test-Path $composerAuth) -and (Select-String -Path $composerAuth -Pattern $Token -SimpleMatch -Quiet) -and (-not $composerAuthNeedsRepair)) { Log "[OK] composer already configured (auth)" } else { if (Test-Path $composerAuth) { Backup-File $composerAuth } else { Track-CreatedFile $composerAuth } Set-ComposerAuthJson -Path $composerAuth -ComposerHost $composerHostName -Token $Token Restrict-FilePermission $composerAuth Log "[OK] composer configured (auth)" } } } else { Log "[SKIP] composer not available" } } # Has-Scope packagist } # --------------------------------------------------------------------------- # Healthcheck functions # --------------------------------------------------------------------------- # --- npm --- # Read the configured Guard mirror directly from the bundler config file # (read-only across Bundler versions). The file path is $BundleConfigPath, # which follows USER_HOME; `bundle config get` would instead answer for the # executing process's own home and misreport a configured device when the # healthcheck runs outside the target user's session. function Get-BundlerConfiguredMirror { if (-not (Test-Path $BundleConfigPath)) { return $null } try { $line = Select-String -Path $BundleConfigPath -Pattern '^BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/:' | Select-Object -First 1 if ($line -and $line.Line -match '^BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/:\s*"?([^"]*)"?\s*$') { return $Matches[1] } } catch {} return $null } # Read the configured Guard host credential ("token:") directly from # the bundler config file (read-only across Bundler versions). function Get-BundlerConfiguredCredential { if (-not (Test-Path $BundleConfigPath)) { return $null } try { $line = Select-String -Path $BundleConfigPath -Pattern '^BUNDLE_RUBYGEMS__FLATT__TECH:' | Select-Object -First 1 if ($line -and $line.Line -match '^BUNDLE_RUBYGEMS__FLATT__TECH:\s*"?([^"]*)"?\s*$') { return $Matches[1] } } catch {} return $null } # Redact the userinfo part of a URL for display. A mirror URL may embed the # token (https://token:tg_...@host/), which must never reach logs. function Hide-UrlUserInfo { param([string]$Url) return ($Url -replace '(https?://)[^/@]*@', '$1***@') } # Extract the "login:password" pair for a host from the netrc file the go # toolchain reads. Handles both the single-line form setup writes and the # multi-line form users may hand-edit. function Get-NetrcCredential { param([string]$MachineHost) if (-not (Test-Path $NetrcPath)) { return $null } try { $tokens = @((Get-Content -Raw $NetrcPath) -split '\s+' | Where-Object { $_ }) for ($i = 0; $i -lt $tokens.Count; $i++) { if ($tokens[$i] -eq 'machine' -and ($i + 1) -lt $tokens.Count -and $tokens[$i + 1] -eq $MachineHost) { $login = '' $password = '' $j = $i + 2 while ($j -lt $tokens.Count -and $tokens[$j] -ne 'machine') { if ($tokens[$j] -eq 'login' -and ($j + 1) -lt $tokens.Count) { $login = $tokens[$j + 1]; $j += 2; continue } if ($tokens[$j] -eq 'password' -and ($j + 1) -lt $tokens.Count) { $password = $tokens[$j + 1]; $j += 2; continue } $j++ } if ($password) { return "${login}:${password}" } return $null } } } catch {} return $null } function Test-NpmConfig { # Phase 1: config check via tool CLI. # # --userconfig points npm at the target user's .npmrc, so a healthcheck run # as SYSTEM still checks that user's configuration. if (-not (Find-Command "npm")) { Log "[SKIP] npm: CLI not found" return 2 } $userNpmrc = Join-Path $UserHome ".npmrc" $registry = $null # npm is a native command; benign stderr must not become a terminating # error under $ErrorActionPreference='Stop', which would leave $registry # empty and report a configured device as misconfigured. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $registry = & npm config get registry --userconfig $userNpmrc 2>$null } catch {} finally { $ErrorActionPreference = $prevEap } if ($registry -and $registry -like '*npm.flatt.tech*') { Log "[OK] npm: registry -> $registry" return 0 } else { Log "[FAIL] npm: registry is '$registry', expected npm.flatt.tech" return 1 } } function Test-NpmConnectivity { # Phase 2: connectivity check via Invoke-WebRequest. try { $resp = Invoke-WebRequest -Uri "https://npm.flatt.tech/-/health" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($resp.StatusCode -eq 200) { Log "[OK] npm: Guard proxy reachable at https://npm.flatt.tech/-/health" return 0 } } catch {} Log "[FAIL] npm: Guard proxy unreachable at https://npm.flatt.tech/-/health" return 1 } function Test-NpmBlock { # Phase 3: block test -- install failure = PASS (inverted semantics). # Runs the real npm CLI with --dry-run so the request travels the same # path as a developer's install: npm's own config resolution, proxy/TLS # settings, and credential attachment. Outcomes: blocked (PASS), # credential rejected (FAIL), not blocked (FAIL), unavailable/network # error (inconclusive). # --dry-run: no files written; --ignore-scripts: no lifecycle scripts. if (-not (Find-Command "npm")) { Log "[SKIP] npm: CLI not found, cannot run block test" return 2 } # Every npm invocation below uses --userconfig, so the test presents the # credential in the target user's .npmrc. $userNpmrc = Join-Path $UserHome ".npmrc" # Run from an empty temp directory: npm reads the working directory's # package.json (a workspace: protocol there aborts the install before it # reaches the registry) and project-level .npmrc, which would make the # verdict depend on where healthcheck happens to be invoked. $probeDir = Join-Path ([System.IO.Path]::GetTempPath()) ("takumi-guard-healthcheck-" + [guid]::NewGuid()) try { New-Item -ItemType Directory -Path $probeDir -Force | Out-Null } catch { Log "[WARN] npm: could not create temp dir for block test (inconclusive)" return 2 } # Whether npm will attach a credential -- npm protects the token value # from `npm config get`, so probe for the key's presence in the effective # config instead (the value itself is never read). Used only to annotate # the result. $authNote = "anonymous -- no credential configured" $output = $null Push-Location $probeDir # Native npm writes diagnostics to stderr; under ErrorActionPreference # 'Stop' the first such line becomes a terminating error and truncates # the captured output to a single line, so relax it around the CLI calls. $prevEAP = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { try { $configList = & npm config list --userconfig $userNpmrc 2>$null | Out-String if ($configList -match [regex]::Escape('//npm.flatt.tech/:_authToken')) { $authNote = "authenticated" } } catch {} # Clear sentinel from local cache so --dry-run must contact the registry. try { & npm cache clean @panda-guard/test-malicious --userconfig $userNpmrc 2>$null } catch {} try { $output = & npm install --dry-run --ignore-scripts --prefer-online ` --userconfig $userNpmrc "@panda-guard/test-malicious" 2>&1 | Out-String } catch { $output = $_.Exception.Message } } finally { $ErrorActionPreference = $prevEAP Pop-Location Remove-Item -Recurse -Force $probeDir -ErrorAction SilentlyContinue } if ($output -match '(?i)E401|401 Unauthorized|unauthorized') { if ($authNote -eq 'authenticated') { Log "[FAIL] npm: configured credential rejected (HTTP 401) -- the token may be revoked; re-issue it or re-run setup" } else { Log "[FAIL] npm: anonymous request rejected (HTTP 401) -- a credential is required but none is configured" } return 1 } elseif ($output -match '(?i)E403|403|forbidden|blocked') { Log "[OK] npm: sentinel package @panda-guard/test-malicious correctly blocked ($authNote)" return 0 } elseif ($output -match '(?i)E404|404|not found|ETARGET|ENETUNREACH|EAI_AGAIN|ECONNREFUSED') { Log "[WARN] npm: sentinel package unavailable or network error (inconclusive)" return 2 } else { Log "[FAIL] npm: sentinel package was NOT blocked -- Guard may not be enforcing policy" return 1 } } # --- RubyGems (Bundler) --- function Test-BundlerConfig { # Phase 1: config check. Reads the config file directly (read-only on # every Bundler version). if (-not (Find-Command "bundle")) { Log "[SKIP] bundler: CLI not found" return 2 } $mirror = Get-BundlerConfiguredMirror if ($mirror -and $mirror -like '*rubygems.flatt.tech*') { Log "[OK] bundler: mirror -> $(Hide-UrlUserInfo $mirror)" return 0 } else { Log "[FAIL] bundler: mirror is '$(Hide-UrlUserInfo $mirror)', expected rubygems.flatt.tech" return 1 } } function Test-BundlerConnectivity { # Phase 2: connectivity check via Invoke-WebRequest. try { $resp = Invoke-WebRequest -Uri "https://rubygems.flatt.tech/-/health" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($resp.StatusCode -eq 200) { Log "[OK] bundler: Guard proxy reachable" return 0 } } catch {} Log "[FAIL] bundler: Guard proxy unreachable at rubygems.flatt.tech" return 1 } function Test-BundlerBlock { # Phase 3: block test via Invoke-WebRequest against Guard proxy, not # bundle install. curl-equivalent approach: no code execution, no file # writes, and it tests the actual blocking behavior. # Guard rubygems proxy returns 403 for blocked gems at /gems/{filename}. if (-not (Find-Command "bundle")) { Log "[SKIP] bundler: CLI not found, cannot read mirror config" return 2 } $mirror = Get-BundlerConfiguredMirror if (-not $mirror) { Log "[SKIP] bundler: no mirror configured, cannot run block test" return 2 } # Strip trailing slash for clean URL construction. $mirror = $mirror.TrimEnd('/') # Send exactly the credential Bundler itself would send on a real # install. Bundler applies the config-file host credential only when the # mirror URL carries no userinfo of its own, so a token embedded in the # URL takes precedence (System.Net does NOT forward userinfo on its own, # so it is converted to an Authorization header). No credential # configured means the anonymous tier -- verify the block anonymously. $authNote = "anonymous -- no credential configured" $cred = $null try { $userInfo = ([System.Uri]$mirror).UserInfo if ($userInfo) { $cred = [System.Uri]::UnescapeDataString($userInfo) } } catch {} if (-not $cred) { $cred = Get-BundlerConfiguredCredential } $headers = @{} if ($cred) { $authNote = "authenticated" $b64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($cred)) $headers['Authorization'] = "Basic $b64" } $httpCode = 0 try { $resp = Invoke-WebRequest -Uri "$mirror/gems/hola-takumi-0.1.0.gem" -UseBasicParsing -TimeoutSec 10 -Headers $headers -ErrorAction Stop $httpCode = $resp.StatusCode } catch { if ($_.Exception.Response) { $httpCode = [int]$_.Exception.Response.StatusCode } } switch ($httpCode) { 403 { Log "[OK] bundler: sentinel gem hola-takumi@0.1.0 correctly blocked (HTTP 403, $authNote)" return 0 } 401 { if ($authNote -eq 'authenticated') { Log "[FAIL] bundler: configured credential rejected (HTTP 401) -- the token may be revoked; re-issue it or re-run setup" } else { Log "[FAIL] bundler: anonymous request rejected (HTTP 401) -- a credential is required but none is configured" } return 1 } { $_ -in @(200, 301, 302) } { Log "[FAIL] bundler: sentinel gem was NOT blocked (HTTP $httpCode) -- Guard may not be enforcing policy" return 1 } default { Log "[WARN] bundler: block test inconclusive (HTTP $httpCode)" return 2 } } } # --- Go --- function Test-GoConfig { # Phase 1: config check via tool CLI. `go env` resolves its env file # through os.UserConfigDir() -- %AppData%\go\env on Windows -- and # $env:APPDATA has already been redirected to $UserHome at script init # when USER_HOME is set, so the probe follows the target user. if (-not (Find-Command "go")) { Log "[SKIP] go: CLI not found" return 2 } $goproxy = $null # Probed from a neutral directory: inside a module whose go directive # names a newer toolchain, `go env` would switch toolchains, and the # verdict would depend on where the healthcheck was invoked. # # `go env` is also a native command, so benign toolchain stderr must not # become a terminating error under $ErrorActionPreference='Stop'. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' Push-Location ([System.IO.Path]::GetTempPath()) try { $goproxy = & go env GOPROXY 2>$null } catch {} finally { Pop-Location $ErrorActionPreference = $prevEap } if ($goproxy -and $goproxy -like '*golang.flatt.tech*') { Log "[OK] go: GOPROXY -> $(Hide-UrlUserInfo $goproxy)" return 0 } else { Log "[FAIL] go: GOPROXY is '$(Hide-UrlUserInfo $goproxy)', expected golang.flatt.tech" return 1 } } function Test-GoConnectivity { # Phase 2: connectivity check via Invoke-WebRequest. try { $resp = Invoke-WebRequest -Uri "https://golang.flatt.tech/-/health" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($resp.StatusCode -eq 200) { Log "[OK] go: Guard proxy reachable" return 0 } } catch {} Log "[FAIL] go: Guard proxy unreachable at golang.flatt.tech" return 1 } function Test-GoBlock { # Phase 3: block test via Invoke-WebRequest against the Guard go proxy, NOT # `go get`. # # Why curl-style probe instead of `go get`: # 1. `go get` has no no-op/dry-run. Under a broken or bypassed Guard it # would download and extract the (possibly compromised) sentinel into # the GLOBAL module cache (GOMODCACHE, outside any tmpdir) and may # re-exec a Go toolchain (GOTOOLCHAIN=auto) -- code-execution and # on-disk surface the read-only healthcheck must never touch. # 2. A go.mod created under the OS temp root is ignored by modern Go, so # a `go get` probe there never reaches the proxy (false FAIL) anyway. # 3. The HTTP probe is zero-risk: no CLI, no code execution, no file # writes, and it tests the actual blocking behavior. The Guard go proxy # returns 403 for blocked modules at the module-metadata endpoint. # Install failure = PASS (inverted semantics). if (-not (Find-Command "go")) { Log "[SKIP] go: CLI not found, cannot run block test" return 2 } # Send exactly the credential the go toolchain would send on a real # fetch: the netrc file it reads. No matching machine entry means the # anonymous tier -- verify the block anonymously. $authNote = "anonymous -- no credential configured" $headers = @{} $cred = Get-NetrcCredential -MachineHost "golang.flatt.tech" if ($cred) { $authNote = "authenticated" $b64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($cred)) $headers['Authorization'] = "Basic $b64" } $httpCode = 0 try { $resp = Invoke-WebRequest -Uri "https://golang.flatt.tech/github.com/flatt-security/hola-takumi-go/@v/v0.1.0.info" ` -UseBasicParsing -TimeoutSec 10 -Headers $headers -ErrorAction Stop $httpCode = [int]$resp.StatusCode } catch { if ($_.Exception.Response) { $httpCode = [int]$_.Exception.Response.StatusCode } } switch ($httpCode) { 403 { Log "[OK] go: sentinel module hola-takumi-go@v0.1.0 correctly blocked (HTTP 403, $authNote)" return 0 } 401 { if ($authNote -eq 'authenticated') { Log "[FAIL] go: configured credential rejected (HTTP 401) -- the token may be revoked; re-issue it or re-run setup" } else { Log "[FAIL] go: anonymous request rejected (HTTP 401) -- a credential is required but none is configured" } return 1 } { $_ -in @(200, 301, 302) } { Log "[FAIL] go: sentinel module was NOT blocked (HTTP $httpCode) -- Guard may not be enforcing policy" return 1 } default { Log "[WARN] go: block test inconclusive (HTTP $httpCode)" return 2 } } } # --- PyPI (stub) --- function Test-PypiConfig { Log "[SKIP] pypi healthcheck not yet supported (no sentinel package published)" return 2 } function Test-PypiConnectivity { return 2 } function Test-PypiBlock { return 2 } # --- Healthcheck orchestrator --- function Invoke-Healthcheck { $hasFail = $false $hasPass = $false # Classify each check's return code: 0 = pass, 1 = fail, # 2 = skip/inconclusive (counts as neither). # Connectivity checks never count toward a pass: reachability alone # proves nothing about blocking, while unreachability is still a # failure. if (Has-Scope "npm") { $rc = Test-NpmConfig if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } $rc = Test-NpmConnectivity if ($rc -eq 1) { $hasFail = $true } $rc = Test-NpmBlock if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } } if (Has-Scope "rubygems") { $rc = Test-BundlerConfig if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } $rc = Test-BundlerConnectivity if ($rc -eq 1) { $hasFail = $true } $rc = Test-BundlerBlock if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } } if (Has-Scope "golang") { $rc = Test-GoConfig if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } $rc = Test-GoConnectivity if ($rc -eq 1) { $hasFail = $true } $rc = Test-GoBlock if ($rc -eq 0) { $hasPass = $true } elseif ($rc -eq 1) { $hasFail = $true } } if (Has-Scope "pypi") { Log "[SKIP] pypi healthcheck not yet supported (no sentinel package)" } if ($hasFail) { Log "" Log "[Error] One or more healthchecks failed" exit 1 } elseif ($hasPass) { Log "" Log "[OK] All healthchecks passed" exit 0 } else { Log "" Log "[WARN] No healthcheck could run conclusively" exit 2 } } # --------------------------------------------------------------------------- # Legacy (all-in-one) orchestrator # --------------------------------------------------------------------------- function Invoke-LegacyMode { Test-UserIdentifier $UserIdentifier Test-SafeString "BOT_ID" $BotId Test-SafeString "TG_BOT_API_KEY" $ApiKey Initialize-BackupDir # Nothing to configure: skip requesting a token and exit. if (-not (Test-HasTarget)) { Log "[Done] No configurable tools found for scopes: $Scopes. Skipping token mint." Invoke-Cleanup exit 0 } # Fail closed before requesting a token: if any config file we would write # is not writable, do not request a token and do not partially configure. # Test-PreflightWritable reports which package manager / path is the problem. if (-not (Test-PreflightWritable)) { Invoke-Cleanup exit 1 } $UniqueTokens = Get-ExistingTokens $Token = $null $AbortUnknown = $false # Walk every discovered candidate and classify it against the server: # "active" -- adopt this token, stop iterating, skip mint. # "revoked" -- discard this token, try the next candidate. # "unknown" -- abort. Silently rewriting config files under an # unverified server state could propagate a dead token # across every package manager. # If every candidate is revoked, or none exists, fall through to mint. # # TG_PREMINTED_TOKEN suppresses the /status probe so that environments # without working API access still succeed via the existing-token path. # Locally discovered tokens still win over the env-supplied value. if ($UniqueTokens.Count -gt 0) { if ($env:TG_PREMINTED_TOKEN) { # Local discovery wins over the env-supplied fallback; pick the # first discovered value without probing /status. The downstream # "[Skip] Existing org token found, reusing" log line then fires. $Token = $UniqueTokens[0] } else { foreach ($candidate in $UniqueTokens) { $status = Get-OrgTokenStatus $candidate if ($status -eq "active") { $Token = $candidate Log "[OK] Existing org token validated as active" break } elseif ($status -eq "unknown") { $AbortUnknown = $true break } # status -eq "revoked" -> continue } } } if ($AbortUnknown) { # In legacy mode `Log` routes to stdout (Write-Output) so [Error] # stays visible under MDM execution; several MDM / remote-shell # hosts capture only stdout. Log "[Error] Could not verify org token status against the Shisho Cloud API (unreachable, timed out, or returned an unexpected response)." Log "[Error] Aborting to avoid rewriting config files with an unverified token. Re-run after the API becomes reachable." Invoke-Rollback # revert anything tracked so far; no-op if nothing yet exit 1 } if ((-not $Token) -and ($UniqueTokens.Count -gt 0) -and (-not $env:TG_PREMINTED_TOKEN)) { Log "[Info] All discovered org tokens are inactive or do not belong to this organisation; minting a fresh one" Log "[Warn] Existing tokens will be overwritten with the new token; any registry access via a different organisation on this device will be lost." } if ($Token) { Log "[Skip] Existing org token found, reusing" # Continue to configure scopes (don't exit -- allows incremental # scope addition). } elseif ($env:TG_PREMINTED_TOKEN) { $Token = $env:TG_PREMINTED_TOKEN if ($Token -notmatch '^tg_org_[A-Za-z0-9_-]{20,}$') { throw "[Error] TG_PREMINTED_TOKEN format unexpected" } Log "[OK] Using pre-minted token" } else { $Token = New-OrgToken $UserIdentifier Log "[OK] Token minted" } Install-Configs $Token Log "[Done] Takumi Guard setup complete" } # --------------------------------------------------------------------------- # Dispatch to subcommand or legacy # --------------------------------------------------------------------------- try { # PowerShell's `switch` does not execute the `default` clause when the # switched value is `$null` (documented quirk: $null doesn't compare to # anything, even via -eq). Branch with an if/else instead so the legacy # fall-through fires whether $Subcommand is `$null` or `''`. if ($Subcommand) { switch ($Subcommand) { 'precheck' { # exit 0: a configurable, writable target exists. # exit 1: nothing configurable here. # exit 3: a configurable target exists but its config file is not # writable (the [Error] diagnostic names which). if (-not (Test-HasTarget)) { exit 1 } if (-not (Test-PreflightWritable)) { exit 3 } exit 0 } 'discover' { $tokens = Get-ExistingTokens if ($tokens.Count -gt 0) { foreach ($t in $tokens) { Write-Result $t } exit 0 } exit 1 } 'verify' { Require-ApiCredentials $result = Get-OrgTokenStatus $SubToken Write-Result $result exit 0 } 'issue' { Require-ApiCredentials Test-UserIdentifier $UserIdentifier $token = New-OrgToken $UserIdentifier Write-Result $token exit 0 } 'install' { if ($SubToken -notmatch '^tg_org_[A-Za-z0-9_-]{20,}$') { Log "[Error] Token format unexpected" exit 1 } # Fail closed before writing anything: if any target is not writable, # configure nothing in this environment (no partial config) and report. if (-not (Test-PreflightWritable)) { exit 1 } Initialize-BackupDir Install-Configs $SubToken exit 0 } 'healthcheck' { Invoke-Healthcheck } } } else { Invoke-LegacyMode } } catch { Invoke-Rollback throw } finally { Invoke-Cleanup }