# 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): # # setup.ps1 precheck [SCOPES] -- exit 0 if anything is configurable # setup.ps1 discover -- print existing tg_org_* tokens (one per line) # setup.ps1 verify -- print active|revoked|unknown # setup.ps1 issue -- mint a new token; print it # setup.ps1 install [SCOPES] -- write package manager configs # # 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 # # Arguments (legacy mode): # BOT_ID Required. Bot ID from Shisho Cloud console. # USER_IDENTIFIER Required. Unique device/user identifier. # Allowed characters: a-z, A-Z, 0-9, hyphen, underscore, dot, at sign, plus. # Length: 4-255 characters. # SCOPES Optional. Comma-separated ecosystems to configure # (default: npm,pypi,rubygems). # # Environment: # TG_BOT_API_KEY Required for legacy mode and for `verify` / `issue`. # Bot API key from Shisho Cloud console. 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. # USER_HOME Optional. Override user home directory (for MDM wrapper # scripts). # TG_API_BASE_URL Optional. Override the API base URL. # # Idempotency: # Legacy mode is safe to run multiple times. If a tg_org_* token is already # present in any config file, the script reuses it without minting a new # token. Config files for the specified scopes are still updated if needed. # # 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 are preserved. # # Prerequisites: # PowerShell 5.1 or later. # Parameter names are preserved from the pre-0.5.0 contract -- existing # integration scripts and CI workflows invoke `setup.ps1 -BotId ... \ # -UserIdentifier ... -Scopes ...` by name, and renaming any of these would # break them silently with `A parameter cannot be found...`. # # 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 MUST run before any function definition so the defense-in- # depth $env:APPDATA / $env:LOCALAPPDATA override (pinned by scenario-43) # is in effect before any downstream function reads them. They have no # dependencies on later code: $UserHome resolves purely from env vars, # and the LocalSystem refusal does not need any helper function. $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" } # 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. Keep the two consumers in sync -- the status probe # runs before mint, so a typo or env override that changes one MUST change # the other. if ($env:TG_API_BASE_URL) { $ApiBaseUrl = $env:TG_API_BASE_URL } else { $ApiBaseUrl = "https://apiv2.cloud.shisho.dev/v1/guard/tokens/org-user" } # --------------------------------------------------------------------------- # Dispatch + argument parsing # --------------------------------------------------------------------------- function Show-Usage { $u = @" Usage: Legacy (one-shot): `$env:TG_BOT_API_KEY="..." ; .\setup.ps1 [SCOPES] Primitive subcommands: .\setup.ps1 precheck [SCOPES] .\setup.ps1 discover .\setup.ps1 verify (env: TG_BOT_ID, TG_BOT_API_KEY) .\setup.ps1 issue (env: TG_BOT_ID, TG_BOT_API_KEY) .\setup.ps1 install [SCOPES] "@ [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' } } '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' } } { $_ -in @('-h', '--help', 'help') } { Show-Usage exit 0 } '' { Show-Usage exit 1 } default { # Legacy mode (pre-0.5.0 contract): $BotId, $UserIdentifier, $Scopes # bind from named or positional args directly. if (-not $UserIdentifier) { Show-Usage; exit 1 } if (-not $Scopes) { $Scopes = 'npm,pypi,rubygems' } } } # 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. 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. # # The released-scripts/README.md note about ps1 using Write-Output for every # log line (including [Error]) is the legacy contract -- it remains in # effect when the script is invoked positionally. Subcommand callers are # our own wrapper scripts, which bridge stderr back to MDM-visible stdout # themselves (see the user-guide wrappers). 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 -- the server # collapses these three cases to a single 404 so the script # treats them uniformly). # 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 { # Non-2xx terminates Invoke-WebRequest. The exception type differs # across PowerShell editions -- Windows PowerShell 5.1 raises # System.Net.WebException while PowerShell 7+ raises # Microsoft.PowerShell.Commands.HttpResponseException -- but both # expose $_.Exception.Response.StatusCode as an HttpStatusCode enum # that casts cleanly to [int]. Every other failure (DNS, timeout, # parameter binding) arrives without a usable Response and stays # unknown. # # The response body is also pulled out here because the 404 branch # below needs it to distinguish a legit /status "token not active" # payload from a bare HTML 404 returned by a CDN or by a # route-not-found path. PS 7+ exposes the decoded body via # ErrorDetails.Message; PS 5.1 only exposes the raw response stream, # so we read from there as a fallback. 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 matches BOTH: # 1. the documented JSON error shape from /status -- i.e. # an `"error":""` field, AND # 2. the literal substring `token not active`, which is the # exact message HandleStatus writes server-side (see the # "CLIENT CONTRACT" comment in # backend/services/api/internal/orgusertoken/handler.go). # A bare 404 with a plain-text / HTML body -- e.g. # "404 page not found" from a CDN or from a deployment window # where /status is not yet served -- or a JSON 404 carrying # some other error message from a different endpoint, must be # classified as "unknown" instead. Classifying a generic 404 as # "revoked" would mark every discovered token as dead and fall # through to mint, BURNING A FRESH BILLED TOKEN ON EVERY SETUP # RUN until /status comes back. 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" -- fail-closed per the # validation-endpoint design. 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. # py.exe may be installed without any Python registered for it (scoop or # other portable Python installs that skip PEP 514 registration); in that # case `py -m pip --version` writes "No installed Python found!" to stderr, # which PowerShell 5.1 surfaces as an ErrorRecord and terminates the script # under `$ErrorActionPreference = "Stop"` even with `2>$null`. Wrap the # probe so the failure mode is "py has no pip", not "script aborts". 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. When USER_HOME is overridden # (the MDM SYSTEM-context wrapper pattern), $env:USERPROFILE / APPDATA / # LOCALAPPDATA all point at the SYSTEM systemprofile, so user-scope tool # installs (scoop, corepack-managed pnpm/yarn, nvm, .bun, .rbenv, .asdf, # mise, per-user RubyInstaller) would be invisible. Find-Command would # then return false, the per-ecosystem branches would fall through, and # the entire deploy would silently mint zero tokens. Substitute USER_HOME # consistently so the well-known list points at the target developer's # profile in either context. $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 (paths match setup.sh). "$UserHome\.rbenv\shims" "$UserHome\.asdf\shims" "$userLocalAppData\mise\shims" "$UserHome\.local\share\mise\shims" ) # 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" } # `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 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. The deduplication preserves discovery order so the # caller's "first hit wins" logic mirrors the per-file iteration above. 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 ) $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 { $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 } } return $found } # --------------------------------------------------------------------------- # 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 } 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. PS 5.1's `Set-Content -Encoding utf8` prepends # a UTF-8 BOM (EF BB BF), which poetry's TOML parser rejects with # "Invalid TOML file: Empty key at line 1 col 0". WriteAllLines with an # explicit UTF8Encoding($false) sidesteps it across PS 5.1 and 7+. [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`"" } } 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)) { 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 if (Get-Command npm -ErrorAction SilentlyContinue) { $existingRegistry = & npm config get registry --location=user 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 & npm config set registry "https://npm.flatt.tech/" --userconfig $npmrc } else { # npm not installed but .npmrc exists -- edit file directly if ((Test-Path $npmrc) -and (Select-String -Path $npmrc -Pattern "npm.flatt.tech" -Quiet)) { (Get-Content $npmrc) -replace '//npm.flatt.tech/:_authToken=.*', "//npm.flatt.tech/:_authToken=$Token" | Set-Content $npmrc } else { Add-NewlineIfMissing $npmrc Add-Content -Path $npmrc -Value "registry=https://npm.flatt.tech/" Add-Content -Path $npmrc -Value "//npm.flatt.tech/:_authToken=$Token" } } Restrict-FilePermission $npmrc Log "[OK] npm configured" } # end tg_org_ check } else { Log "[SKIP] npm not available" } # --- pnpm (global rc) --- 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 if (Select-String -Path $pnpmRc -Pattern "npm.flatt.tech" -Quiet) { (Get-Content $pnpmRc) -replace '//npm.flatt.tech/:_authToken=.*', "//npm.flatt.tech/:_authToken=$Token" | Set-Content $pnpmRc } else { Add-NewlineIfMissing $pnpmRc Add-Content -Path $pnpmRc -Value "registry=https://npm.flatt.tech/" Add-Content -Path $pnpmRc -Value "//npm.flatt.tech/:_authToken=$Token" } Restrict-FilePermission $pnpmRc Log "[OK] pnpm configured" } } elseif (Find-Command pnpm) { if (-not (Test-Path $pnpmRcDir)) { New-Item -ItemType Directory -Path $pnpmRcDir -Force | Out-Null } Set-Content -Path $pnpmRc -Value "registry=https://npm.flatt.tech/" Add-Content -Path $pnpmRc -Value "//npm.flatt.tech/:_authToken=$Token" Track-CreatedFile $pnpmRc Restrict-FilePermission $pnpmRc Log "[OK] pnpm configured" } else { Log "[SKIP] pnpm not available" } # --- yarn v2+ (.yarnrc.yml) --- $yarnrc = Join-Path $UserHome ".yarnrc.yml" if (Test-Path $yarnrc) { if (Select-String -Path $yarnrc -Pattern $Token -SimpleMatch -Quiet) { Log "[OK] yarn already configured" } else { Backup-File $yarnrc if (Select-String -Path $yarnrc -Pattern "npm.flatt.tech" -Quiet) { if (Select-String -Path $yarnrc -Pattern "npmAuthToken" -Quiet) { (Get-Content $yarnrc) -replace 'npmAuthToken:.*', "npmAuthToken: `"$Token`"" | Set-Content $yarnrc } else { $content = Get-Content $yarnrc $newContent = @() foreach ($line in $content) { $newContent += $line if ($line -match 'npmRegistryServer:') { $newContent += "npmAuthToken: `"$Token`"" } } $newContent | Set-Content $yarnrc } } else { if (Select-String -Path $yarnrc -Pattern "npmRegistryServer" -Quiet) { (Get-Content $yarnrc) -replace 'npmRegistryServer:.*', "npmRegistryServer: `"https://npm.flatt.tech/`"" | Set-Content $yarnrc $content = Get-Content $yarnrc $newContent = @() foreach ($line in $content) { $newContent += $line if ($line -match 'npmRegistryServer:') { $newContent += "npmAuthToken: `"$Token`"" } } $newContent | Set-Content $yarnrc } else { Add-Content -Path $yarnrc -Value "" Add-Content -Path $yarnrc -Value "npmRegistryServer: `"https://npm.flatt.tech/`"" Add-Content -Path $yarnrc -Value "npmAuthToken: `"$Token`"" } } Restrict-FilePermission $yarnrc Log "[OK] yarn configured" } # end tg_org_ check } elseif (Find-Command yarn) { Set-Content -Path $yarnrc -Value "npmRegistryServer: `"https://npm.flatt.tech/`"" Add-Content -Path $yarnrc -Value "npmAuthToken: `"$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 if (Select-String -Path $bunfig -Pattern "npm.flatt.tech" -Quiet) { (Get-Content $bunfig) -replace '(npm\.flatt\.tech.*?token\s*=\s*)"[^"]*"', "`$1`"$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) { 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 if (Select-String -Path $pipConf -Pattern "pypi.flatt.tech" -Quiet) { (Get-Content $pipConf) -replace 'index-url = .*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)) { 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 if (Select-String -Path $uvConf -Pattern "pypi.flatt.tech" -Quiet) { (Get-Content $uvConf) -replace 'url = ".*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) { 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 --- # # Why direct file writes instead of `poetry config`: # # Poetry on Windows resolves its config dir via `platformdirs`, which # calls the Win32 API `SHGetKnownFolderPath(FOLDERID_RoamingAppData)`. # That API reads from the calling token's HKCU shell-folders registry, # not from $env:APPDATA. Under the MDM SYSTEM-context wrapper (the # documented deployment pattern), the calling token is SYSTEM, so # poetry writes to # `C:\Windows\System32\config\systemprofile\AppData\Roaming\pypoetry` # regardless of how we patch APPDATA in the script's process # environment. Overriding HKCU at runtime would be invasive and would # race with the launching session. Writing the TOML directly is # portable across both logged-in user execution and SYSTEM+USER_HOME # execution, and matches the pattern the script already uses for # pip.ini / uv.toml / .npmrc / etc. if (Find-Command poetry) { $poetryConfigDir = Join-Path $UserHome "AppData\Roaming\pypoetry" $poetryAuth = Join-Path $poetryConfigDir "auth.toml" $poetryConf = Join-Path $poetryConfigDir "config.toml" if ((Test-Path $poetryAuth) -and (Select-String -Path $poetryAuth -Pattern $Token -SimpleMatch -Quiet)) { Log "[OK] poetry already configured" } else { if (-not (Test-Path $poetryConfigDir)) { New-Item -ItemType Directory -Path $poetryConfigDir -Force | Out-Null } if (Test-Path $poetryConf) { Backup-File $poetryConf } else { Track-CreatedFile $poetryConf } if (Test-Path $poetryAuth) { Backup-File $poetryAuth } else { Track-CreatedFile $poetryAuth } Set-PoetryTomlSection -Path $poetryConf -Section "repositories.takumi-guard" -Lines @( 'url = "https://pypi.flatt.tech/simple/"' ) Set-PoetryTomlSection -Path $poetryAuth -Section "http-basic.takumi-guard" -Lines @( 'username = "token"', "password = `"$Token`"" ) Restrict-FilePermission $poetryAuth Restrict-FilePermission $poetryConf 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)) { 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 } # --------------------------------------------------------------------------- # 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 if (-not (Test-HasTarget)) { Log "[Done] No configurable tools found for scopes: $Scopes. Skipping token mint." Invoke-Cleanup exit 0 } $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' { if (Test-HasTarget) { exit 0 } else { exit 1 } } '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 } Initialize-BackupDir Install-Configs $SubToken exit 0 } } } else { Invoke-LegacyMode } } catch { Invoke-Rollback throw } finally { Invoke-Cleanup }