Files
vs-code-setup/scripts/common.ps1
T
max d45639bf2f Build, publish to Gitea releases, and install the three extensions
Answers two things: how to install these without a marketplace, and how to keep
settings in sync when VS Code's Settings Sync cannot be pointed at Gitea.

- build.ps1 packages each extension into dist/
- publish.ps1 creates a release and uploads them, replacing same-named assets so a
  rolling "latest" tag stays clean
- install.ps1 downloads a release's assets and installs them, or -Local from dist/
- settings.ps1 copies User/ between the repo and the machine, and installs the
  marketplace extensions recorded in extensions.txt
- dev-link.ps1 junctions the sources into ~/.vscode/extensions for development
- ci-release.sh plus a Gitea Actions workflow do the build and publish on a tag push

Settings are copied, not symlinked: an atomic save replaces a symlink with a regular
file and the sync stops without looking broken.

vsce/ pins vsce and undici so packaging works on Node 18, which current vsce does not
support.

test/run.ps1 drives the real scripts against a stub of the Gitea release API. It
caught the multipart Content-Disposition being unquoted on .NET Framework, and
Invoke-WebRequest dropping the Authorization header across a redirect.
2026-09-07 19:35:20 +02:00

302 lines
12 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 $env:USERPROFILE '.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 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 = @(
(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')
)
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.")
}