Home, VS Code user folder and CLI are resolved per platform (with VSCODE_USER_DIR as an override); dev-link makes a symlink where a junction is not available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
345 lines
13 KiB
PowerShell
345 lines
13 KiB
PowerShell
# 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 <gitea>/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 <gitea>/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.")
|
|
}
|