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.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# 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.")
|
||||
}
|
||||
Reference in New Issue
Block a user