# Shared helpers: configuration, the Gitea release API, and locating vsce. # Dot-source this; it defines functions and does nothing on its own. Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Windows PowerShell 5.1 still defaults to SSL3/TLS1.0, which no current Gitea accepts. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 $script:RepoRoot = Split-Path -Parent $PSScriptRoot function Get-RepoRoot { $script:RepoRoot } function Get-SetupConfig { <# The repo's config.json, with GITEA_URL / GITEA_OWNER / GITEA_REPO taking precedence so a machine can point somewhere else without editing the file. -RequireGitea additionally insists the server has been filled in. Only the scripts that talk to Gitea ask for that: building, linking and settings all work on a checkout nobody has configured yet. #> param([switch] $RequireGitea) $path = Join-Path $script:RepoRoot 'config.json' if (-not (Test-Path $path)) { throw "config.json not found at $path" } $config = Get-Content $path -Raw | ConvertFrom-Json foreach ($pair in @( @{ Env = 'GITEA_URL'; Key = 'url' }, @{ Env = 'GITEA_OWNER'; Key = 'owner' }, @{ Env = 'GITEA_REPO'; Key = 'repo' } )) { $value = [Environment]::GetEnvironmentVariable($pair.Env) if ($value) { $config.gitea.($pair.Key) = $value } } $config.gitea.url = $config.gitea.url.TrimEnd('/') if ($RequireGitea -and $config.gitea.url -like '*example.com*') { throw ('config.json still has the placeholder Gitea URL. Set it there, or set ' + 'the GITEA_URL, GITEA_OWNER and GITEA_REPO environment variables.') } $config } function Get-ExtensionPath { <# Absolute path to an extension's source, from its repo-relative config entry. #> param([Parameter(Mandatory)] $Extension) [System.IO.Path]::GetFullPath((Join-Path $script:RepoRoot $Extension.path)) } function Get-DistDir { $dist = Join-Path $script:RepoRoot 'dist' if (-not (Test-Path $dist)) { New-Item -ItemType Directory -Path $dist | Out-Null } $dist } function Get-GiteaToken { <# A token with write access to the release repo, from (in order) the argument, GITEA_TOKEN, or %USERPROFILE%\.gitea-token. Never read from inside the repo, so a token cannot be committed by accident. Create one at /user/settings/applications with scope write:repository. #> param([string] $Token) if ($Token) { return $Token } if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN } $file = Join-Path (Get-HomeDir) '.gitea-token' if (Test-Path $file) { $value = (Get-Content $file -Raw).Trim() if ($value) { return $value } } throw ("No Gitea token. Set the GITEA_TOKEN environment variable, write one to " + "$file, or pass -Token. Create it at /user/settings/applications " + "with write:repository scope.") } function Invoke-Gitea { <# A JSON call against the Gitea API. Paths are relative to /api/v1. #> param( [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Token, [string] $Method = 'GET', $Body, [switch] $AllowNotFound ) $config = Get-SetupConfig -RequireGitea $uri = "$($config.gitea.url)/api/v1$Path" $arguments = @{ Uri = $uri Method = $Method Headers = @{ Authorization = "token $Token"; Accept = 'application/json' } UseBasicParsing = $true } if ($null -ne $Body) { $arguments.Body = ($Body | ConvertTo-Json -Depth 6) $arguments.ContentType = 'application/json' } try { Invoke-RestMethod @arguments } catch { $status = 0 if ($_.Exception.Response) { $status = [int] $_.Exception.Response.StatusCode } if ($AllowNotFound -and $status -eq 404) { return $null } $detail = "Gitea $Method $Path failed" if ($status) { $detail += " with HTTP $status" } throw "${detail}: $($_.Exception.Message)" } } function Send-GiteaAsset { <# Uploads one file as a release asset. Gitea only accepts multipart/form-data here, and Invoke-RestMethod cannot build that on 5.1 (-Form is PowerShell 6+), so this goes through HttpClient. That also streams the file rather than reading it into a string, which matters because a .vsix is a zip and any text encoding step would corrupt it. #> param( [Parameter(Mandatory)] [int] $ReleaseId, [Parameter(Mandatory)] [string] $FilePath, [Parameter(Mandatory)] [string] $Token ) Add-Type -AssemblyName System.Net.Http $config = Get-SetupConfig -RequireGitea $name = [System.IO.Path]::GetFileName($FilePath) $uri = ("$($config.gitea.url)/api/v1/repos/$($config.gitea.owner)/" + "$($config.gitea.repo)/releases/$ReleaseId/assets" + "?name=$([Uri]::EscapeDataString($name))") $client = $null; $content = $null; $stream = $null try { $client = New-Object System.Net.Http.HttpClient $client.Timeout = [TimeSpan]::FromMinutes(10) $client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('token', $Token) $content = New-Object System.Net.Http.MultipartFormDataContent $stream = [System.IO.File]::OpenRead($FilePath) $file = New-Object System.Net.Http.StreamContent($stream) $file.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue('application/octet-stream') # Set Content-Disposition rather than letting Add(content, name, fileName) do it. # On .NET Framework that overload emits an unquoted `name=attachment` plus an # RFC 5987 `filename*`; Gitea's Go parser copes, but this sends exactly what curl # -F sends and leaves nothing to the server's leniency. The quotes have to be # written in by hand -- the setters store the value verbatim. $disposition = New-Object System.Net.Http.Headers.ContentDispositionHeaderValue('form-data') $disposition.Name = '"attachment"' $disposition.FileName = '"' + $name + '"' $file.Headers.ContentDisposition = $disposition $content.Add($file) $response = $client.PostAsync($uri, $content).GetAwaiter().GetResult() $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { throw "uploading $name failed with HTTP $([int] $response.StatusCode): $text" } $text | ConvertFrom-Json } finally { if ($stream) { $stream.Dispose() } if ($content) { $content.Dispose() } if ($client) { $client.Dispose() } } } function Get-GiteaRelease { <# The release for a tag, or nothing if there is none. #> param( [Parameter(Mandatory)] [string] $Tag, [Parameter(Mandatory)] [string] $Token ) $config = Get-SetupConfig -RequireGitea $tagPath = [Uri]::EscapeDataString($Tag) Invoke-Gitea -Token $Token -AllowNotFound ` -Path "/repos/$($config.gitea.owner)/$($config.gitea.repo)/releases/tags/$tagPath" } function Save-GiteaAsset { <# Downloads a release asset. Redirects are followed here by hand instead of using Invoke-WebRequest, because a private repo needs the token and Gitea answers browser_download_url with a redirect to the attachment -- and every automatic redirect follower drops the Authorization header on the way, which comes back as a 401 that looks like a bad token. The header is re-sent only when the redirect stays on the same host, so a redirect off the instance cannot walk away with the token. #> param( [Parameter(Mandatory)] [string] $Url, [Parameter(Mandatory)] [string] $Destination, [string] $Token, [int] $MaxHops = 5 ) Add-Type -AssemblyName System.Net.Http $origin = ([Uri] $Url).Host $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $false $client = New-Object System.Net.Http.HttpClient($handler) $client.Timeout = [TimeSpan]::FromMinutes(10) try { $current = [Uri] $Url for ($hop = 0; $hop -le $MaxHops; $hop++) { $request = New-Object System.Net.Http.HttpRequestMessage('GET', $current) if ($Token -and $current.Host -eq $origin) { $request.Headers.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('token', $Token) } $response = $client.SendAsync( $request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead ).GetAwaiter().GetResult() try { $status = [int] $response.StatusCode if ($status -ge 300 -and $status -lt 400 -and $response.Headers.Location) { $next = $response.Headers.Location if (-not $next.IsAbsoluteUri) { $next = New-Object Uri($current, $next) } $current = $next continue } if (-not $response.IsSuccessStatusCode) { throw "downloading $current failed with HTTP $status" } $input_ = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $output = [System.IO.File]::Create($Destination) try { $input_.CopyTo($output) } finally { $output.Dispose(); $input_.Dispose() } if ((Get-Item $Destination).Length -eq 0) { throw "downloaded $current but the file is empty" } return } finally { $response.Dispose() } } throw "more than $MaxHops redirects starting at $Url" } finally { $client.Dispose() $handler.Dispose() } } function Get-VsceCommand { <# Path to the vsce entry script, installing it on first use. It lives in vsce/ with its own pinned dependencies rather than being run through npx, because current vsce needs Node 20+ and on Node 18 it dies with "ReferenceError: File is not defined" from a transitive undici. vsce/package.json pins both. On Node 20 or newer none of this matters and plain "npx @vscode/vsce package" would do. #> $vsceRoot = Join-Path $script:RepoRoot 'vsce' $entry = Join-Path $vsceRoot 'node_modules/@vscode/vsce/vsce' if (-not (Test-Path $entry)) { Write-Host "installing vsce into $vsceRoot (first run only)..." -ForegroundColor DarkGray Push-Location $vsceRoot try { & npm install --silent --no-audit --no-fund if ($LASTEXITCODE -ne 0) { throw 'npm install failed in vsce/' } } finally { Pop-Location } } if (-not (Test-Path $entry)) { throw "vsce still not present at $entry" } $entry } function Test-IsWindows { <# Windows PowerShell 5.1 has no $IsWindows (it only ever runs on Windows); pwsh 6+ defines it on every platform. So: undefined means Windows. #> if (Test-Path variable:IsWindows) { return [bool] $IsWindows } return $true } function Get-HomeDir { <# $HOME is set by both editions on every platform; USERPROFILE only exists on Windows. #> if ($HOME) { return $HOME } if ($env:USERPROFILE) { return $env:USERPROFILE } throw 'Neither $HOME nor USERPROFILE is set.' } function Get-CodeUserDir { <# VS Code's user folder, where settings.json and keybindings.json live. Set VSCODE_USER_DIR to override, e.g. for Insiders or a portable install. #> if ($env:VSCODE_USER_DIR) { return $env:VSCODE_USER_DIR } if (Test-IsWindows) { return Join-Path $env:APPDATA 'Code\User' } if ((Test-Path variable:IsMacOS) -and $IsMacOS) { return Join-Path (Get-HomeDir) 'Library/Application Support/Code/User' } $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path (Get-HomeDir) '.config' } return Join-Path $configHome 'Code/User' } function Get-CodeCommand { <# The VS Code CLI. Not always on PATH, so fall back to the usual install locations. #> $onPath = Get-Command code -ErrorAction SilentlyContinue if ($onPath) { return $onPath.Source } $candidates = if (Test-IsWindows) { @( (Join-Path $env:LOCALAPPDATA 'Programs\Microsoft VS Code\bin\code.cmd'), 'C:\Program Files\Microsoft VS Code\bin\code.cmd', (Join-Path $env:LOCALAPPDATA 'Programs\Microsoft VS Code Insiders\bin\code-insiders.cmd') ) } else { @( '/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', '/var/lib/flatpak/exports/bin/com.visualstudio.code', (Join-Path (Get-HomeDir) '.local/bin/code'), '/usr/local/bin/code', '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', '/usr/bin/code-insiders' ) } foreach ($candidate in $candidates) { if (Test-Path $candidate) { return $candidate } } throw ("The 'code' CLI was not found. In VS Code run " + "'Shell Command: Install code command in PATH', or add its bin/ to PATH.") }