# Takumi Guard token provisioning # Usage: $env:TG_BOT_API_KEY="..." ; .\takumi-guard-setup-{VERSION}.ps1 [SCOPES] # # Mints a new org user token and configures package managers to use Takumi Guard. # Designed to be called by an MDM deployment script. # # Supported: # npm ecosystem -- npm, pnpm, yarn v2+, bun # PyPI ecosystem -- pip, uv, poetry # RubyGems ecosystem -- Bundler # # Arguments: # 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. Bot API key from Shisho Cloud console. # Passed via env var to avoid shell history / process table exposure. # USER_HOME Optional. Override user home directory (for MDM wrapper scripts). # # Idempotency: # This script 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. param( [Parameter(Mandatory=$true, Position=0)][string]$BotId, [Parameter(Mandatory=$true, Position=1)][string]$UserIdentifier, [string]$Scopes = "npm,pypi,rubygems" ) $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Argument validation # --------------------------------------------------------------------------- $ApiKey = $env:TG_BOT_API_KEY if ($env:USER_HOME) { $UserHome = $env:USER_HOME } else { $UserHome = $env:USERPROFILE } if (-not $ApiKey) { throw "Set TG_BOT_API_KEY environment variable" } 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" } # Refuse LocalSystem invocation (SID S-1-5-18) without an explicit # USER_HOME: $env:USERPROFILE points at the systemprofile path that # never reaches the real user. $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value 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." } 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 } Test-UserIdentifier $UserIdentifier Test-SafeString "BOT_ID" $BotId Test-SafeString "TG_BOT_API_KEY" $ApiKey # 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" } # 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 of # the following: # 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. The double check turns that silent # failure into an explicit fail-closed abort. 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" } } } 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") } # 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 { Write-Output "[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") } } # --------------------------------------------------------------------------- # Backup and rollback # --------------------------------------------------------------------------- $Timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $TmpBackupDir = Join-Path ([System.IO.Path]::GetTempPath()) "guard-backup-$Timestamp" New-Item -ItemType Directory -Path $TmpBackupDir -Force | Out-Null $TmpBackupFiles = @() $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 Write-Output "[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 } Write-Output "[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 Write-Output "[Rollback] Restored $src" } } foreach ($src in $script:CreatedFiles) { if (Test-Path $src) { Remove-Item $src -Force Write-Output "[Rollback] Removed $src" } } Remove-Item $TmpBackupDir -Recurse -Force -ErrorAction SilentlyContinue } function Invoke-Cleanup { Remove-Item $TmpBackupDir -Recurse -Force -ErrorAction SilentlyContinue } try { # --------------------------------------------------------------------------- # Check for existing tg_org_* token # --------------------------------------------------------------------------- # Collect every distinct tg_org_* value already present on disk. The # iteration below adopts the first ACTIVE candidate, so a revoked token # in one config file does not mask an active one in another. if ($env:XDG_CONFIG_HOME) { $xdgPnpmRc = Join-Path $env:XDG_CONFIG_HOME "pnpm\rc" } else { $xdgPnpmRc = Join-Path $UserHome "AppData\Local\pnpm\config\rc" } $bundleConfig = Get-BundleConfigPath $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"), $bundleConfig ) $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 } } } # Deduplicate while preserving discovery order (Sort-Object -Unique would # lose order). The result is an [object[]] of unique tokens (empty if # nothing was found). $UniqueTokens = @() foreach ($t in $TokensFound) { if ($UniqueTokens -notcontains $t) { $UniqueTokens += $t } } $Token = $null # --------------------------------------------------------------------------- # Pre-check: ensure at least one configurable tool or config file exists # --------------------------------------------------------------------------- # Without this check, a clean environment (no tools, no config files) would # mint a token on every run but never write it anywhere -- wasting tokens. $HasTarget = $false if (Has-Scope "npm") { if ((Test-Path (Join-Path $UserHome ".npmrc")) -or (Get-Command npm -ErrorAction SilentlyContinue)) { $HasTarget = $true } if (Find-Command pnpm) { $HasTarget = $true } if (Test-Path (Join-Path $UserHome ".yarnrc.yml")) { $HasTarget = $true } if (Find-Command yarn) { $HasTarget = $true } if (Test-Path (Join-Path $UserHome ".bunfig.toml")) { $HasTarget = $true } if (Find-Command bun) { $HasTarget = $true } } if (Has-Scope "pypi") { if (Test-Path (Join-Path $UserHome "AppData\Roaming\pip\pip.ini")) { $HasTarget = $true } if ((Find-Command pip3) -or (Find-Command pip)) { $HasTarget = $true } if (Test-PyHasPip) { $HasTarget = $true } if (Test-Path (Join-Path $UserHome "AppData\Roaming\uv\uv.toml")) { $HasTarget = $true } if (Find-Command uv) { $HasTarget = $true } if (Find-Command poetry) { $HasTarget = $true } } if (Has-Scope "rubygems") { if (Test-Path $bundleConfig) { $HasTarget = $true } if (Find-Command bundle) { $HasTarget = $true } if (Find-Command ruby) { $HasTarget = $true } } if (-not $HasTarget) { Write-Output "[Done] No configurable tools found for scopes: $Scopes. Skipping token mint." Invoke-Cleanup exit 0 } # 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. $AbortUnknown = $false 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 Write-Output "[OK] Existing org token validated as active" break } elseif ($status -eq "unknown") { $AbortUnknown = $true break } # status -eq "revoked" → continue } } } if ($AbortUnknown) { # Use Write-Output (stdout) so [Error] messages stay visible under # MDM execution; several MDM / remote-shell hosts capture only stdout. Write-Output "[Error] Could not verify org token status against the Shisho Cloud API (unreachable, timed out, or returned an unexpected response)." Write-Output "[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)) { Write-Output "[Info] All discovered org tokens are inactive or do not belong to this organisation; minting a fresh one" Write-Output "[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) { Write-Output "[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" } Write-Output "[OK] Using pre-minted token" } else { # --------------------------------------------------------------------------- # Mint a new org user token # --------------------------------------------------------------------------- $body = @{ bot_id = $BotId api_key = $ApiKey user_identifier = $UserIdentifier } | ConvertTo-Json try { $response = Invoke-RestMethod -Uri $ApiBaseUrl -Method Post -Body $body -ContentType "application/json" $Token = $response.token } catch { $statusCode = $_.Exception.Response.StatusCode.value__ Write-Output "[Error] Token API returned HTTP $statusCode" Write-Output $_.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" } Write-Output "[OK] Token minted" } # --------------------------------------------------------------------------- # 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)) { Write-Output "[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") { Write-Output "[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 Write-Output "[OK] npm configured" } # end tg_org_ check } else { Write-Output "[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) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] pnpm configured" } else { Write-Output "[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) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] yarn configured" } else { Write-Output "[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) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] bun configured" } else { Write-Output "[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) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] pip configured" } else { Write-Output "[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) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] uv configured" } else { Write-Output "[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. # 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)) } 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)) { Write-Output "[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 Write-Output "[OK] poetry configured" } } else { Write-Output "[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 was already resolved above (via Get-BundleConfigPath) for # token reuse and pre-check; reuse the same value here. $bundleDir = Split-Path $bundleConfig -Parent $bundleKey = 'BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/' $bundleValue = 'https://rubygems.flatt.tech/' $bundleCredKey = 'BUNDLE_RUBYGEMS__FLATT__TECH' $bundleCredValue = "token:$Token" # 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`"" } } if (Test-Path $bundleConfig) { if (Select-String -Path $bundleConfig -Pattern $Token -SimpleMatch -Quiet) { Write-Output "[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 Write-Output "[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 Write-Output "[OK] bundler configured" } else { Write-Output "[SKIP] bundler not available" } } # Has-Scope rubygems Write-Output "[Done] Takumi Guard setup complete" } catch { Invoke-Rollback throw } finally { Invoke-Cleanup }