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:
max
2026-09-07 19:35:20 +02:00
commit d45639bf2f
15 changed files with 4043 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
<#
.SYNOPSIS
Packages the extensions into dist/*.vsix.
.DESCRIPTION
For each extension in config.json: install dependencies if they are missing, then
run vsce, which compiles through the vscode:prepublish script. The .vsix files land
in dist/, which publish.ps1 uploads and install.ps1 can install from directly.
.PARAMETER Only
Build just these extension ids.
.PARAMETER Clone
Clone any extension whose configured path does not exist yet, from its Gitea repo.
.PARAMETER Pull
git pull each extension before building.
.EXAMPLE
.\scripts\build.ps1
.\scripts\build.ps1 -Only vertical-tabs
.\scripts\build.ps1 -Clone -Pull
#>
[CmdletBinding()]
param(
[string[]] $Only,
[switch] $Clone,
[switch] $Pull
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$config = Get-SetupConfig
$dist = Get-DistDir
$vsce = Get-VsceCommand
$extensions = $config.extensions
if ($Only) {
$extensions = $extensions | Where-Object { $Only -contains $_.id }
$missing = $Only | Where-Object { $config.extensions.id -notcontains $_ }
if ($missing) { throw "not in config.json: $($missing -join ', ')" }
}
$built = @()
foreach ($extension in $extensions) {
$path = Get-ExtensionPath $extension
Write-Host ''
Write-Host "=== $($extension.id)" -ForegroundColor Cyan
if (-not (Test-Path $path)) {
if (-not $Clone) {
throw ("$path does not exist. Pass -Clone to clone it from " +
"$($config.gitea.url)/$($config.gitea.owner)/$($extension.repo).")
}
$url = "$($config.gitea.url)/$($config.gitea.owner)/$($extension.repo).git"
Write-Host "cloning $url" -ForegroundColor DarkGray
& git clone $url $path
if ($LASTEXITCODE -ne 0) { throw "git clone failed for $($extension.id)" }
} elseif ($Pull) {
Write-Host 'git pull' -ForegroundColor DarkGray
& git -C $path pull --ff-only
if ($LASTEXITCODE -ne 0) { throw "git pull failed for $($extension.id)" }
}
$manifestPath = Join-Path $path 'package.json'
if (-not (Test-Path $manifestPath)) { throw "no package.json in $path" }
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
Push-Location $path
try {
if (-not (Test-Path (Join-Path $path 'node_modules'))) {
Write-Host 'installing dependencies...' -ForegroundColor DarkGray
if (Test-Path (Join-Path $path 'package-lock.json')) {
& npm ci --silent --no-audit --no-fund
} else {
& npm install --silent --no-audit --no-fund
}
if ($LASTEXITCODE -ne 0) { throw "dependency install failed for $($extension.id)" }
}
# vsce runs vscode:prepublish, so this compiles as well as packages.
# --allow-missing-repository keeps it from refusing a package.json with no
# repository field; it is harmless once that field is filled in.
$out = Join-Path $dist "$($manifest.name)-$($manifest.version).vsix"
& node $vsce package --out $out --allow-missing-repository
if ($LASTEXITCODE -ne 0) { throw "vsce package failed for $($extension.id)" }
$size = [math]::Round((Get-Item $out).Length / 1KB, 1)
Write-Host " -> $(Split-Path -Leaf $out) ($size KB)" -ForegroundColor Green
$built += $out
} finally { Pop-Location }
}
Write-Host ''
Write-Host "$($built.Count) package(s) in $dist" -ForegroundColor Cyan
$built
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Packages every extension in config.json and uploads the .vsix files to a Gitea release.
#
# This is the CI half of publish.ps1: the same steps, in bash, for a Linux runner with no
# PowerShell. Driven by .gitea/workflows/release.yml, but it runs anywhere with bash, git,
# node and curl. JSON goes through node rather than jq, because node has to be there
# anyway to build the extensions and jq often is not.
#
# Environment:
# GITEA_SERVER base URL, e.g. https://gitea.example.com (CI: GITHUB_SERVER_URL)
# GITEA_OWNER owner of the release repo (CI: GITHUB_REPOSITORY_OWNER)
# GITEA_REPO name of the release repo
# GITEA_TOKEN token with write:repository
# TAG release tag, default "latest"
# SKIP_CLONE non-empty: fail instead of cloning a missing extension
# SKIP_PACKAGE non-empty: upload whatever is already in dist/, build nothing
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
config="$root/config.json"
dist="$root/dist"
tag="${TAG:-latest}"
for tool in git node curl; do
command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; }
done
for name in GITEA_SERVER GITEA_OWNER GITEA_REPO GITEA_TOKEN; do
[ -n "${!name:-}" ] || { echo "missing $name" >&2; exit 1; }
done
server="${GITEA_SERVER%/}"
api="$server/api/v1/repos/$GITEA_OWNER/$GITEA_REPO"
auth=(-H "Authorization: token $GITEA_TOKEN")
# Evaluates a JavaScript expression against JSON on stdin, one line per array element.
# Unparseable input yields nothing, so a 404 body reads as "no release".
jget() {
node -e '
let raw = "";
process.stdin.on("data", chunk => { raw += chunk; }).on("end", () => {
let json;
try { json = JSON.parse(raw); } catch (error) { return; }
const value = Function("j", "return (" + process.argv[1] + ")")(json);
if (value === undefined || value === null) { return; }
if (Array.isArray(value)) { value.forEach(item => console.log(item)); }
else { console.log(value); }
});
' "$1"
}
mkdir -p "$dist"
# --- package ---------------------------------------------------------------------
if [ -n "${SKIP_PACKAGE:-}" ]; then
echo "SKIP_PACKAGE set, using what is already in dist/"
else
rm -f "$dist"/*.vsix
node -e '
const config = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
for (const extension of config.extensions) {
console.log([extension.id, extension.path, extension.repo].join("\t"));
}
' "$config" > "$dist/.extensions.tsv"
while IFS=$'\t' read -r id relative repo; do
[ -n "$id" ] || continue
path="$root/$relative"
echo "=== $id"
if [ ! -d "$path" ]; then
[ -z "${SKIP_CLONE:-}" ] || { echo " $path missing and SKIP_CLONE set" >&2; exit 1; }
# oauth2:<token> is how Gitea takes a token over https, so private repos clone.
authed="$(echo "$server" | sed -E "s#^(https?://)#\1oauth2:$GITEA_TOKEN@#")"
git clone --depth 1 "$authed/$GITEA_OWNER/$repo.git" "$path"
fi
pushd "$path" >/dev/null
if [ -f package-lock.json ]; then
npm ci --no-audit --no-fund
else
npm install --no-audit --no-fund
fi
name="$(node -p "require('./package.json').name")"
version="$(node -p "require('./package.json').version")"
npx --yes @vscode/vsce package \
--out "$dist/$name-$version.vsix" --allow-missing-repository
popd >/dev/null
done < "$dist/.extensions.tsv"
rm -f "$dist/.extensions.tsv"
fi
ls -l "$dist"/*.vsix
# --- release ---------------------------------------------------------------------
release="$(curl -sS "${auth[@]}" "$api/releases/tags/$tag" || true)"
id="$(printf '%s' "$release" | jget 'j.id')"
if [ -z "$id" ]; then
echo "creating release $tag"
body="$(TAG="$tag" node -e '
process.stdout.write(JSON.stringify({
tag_name: process.env.TAG,
name: "VS Code extensions (" + process.env.TAG + ")",
body: "Built by CI. Install with scripts/install.ps1.",
draft: false,
prerelease: false,
}));
')"
created="$(curl -sS -f -X POST "${auth[@]}" \
-H 'Content-Type: application/json' -d "$body" "$api/releases")"
id="$(printf '%s' "$created" | jget 'j.id')"
[ -n "$id" ] || { echo "could not create the release: $created" >&2; exit 1; }
release='{"assets":[]}'
fi
echo "release id $id"
# Uploads run from inside dist/ so curl gets a bare filename. An absolute path would
# do on Linux, but curl built for Windows cannot open an MSYS-style /d/... path, and
# a relative name is unambiguous everywhere.
pushd "$dist" >/dev/null
for base in *.vsix; do
# Replace rather than duplicate, so republishing the same tag stays clean.
old="$(printf '%s' "$release" | ASSET_NAME="$base" \
jget '(j.assets||[]).filter(a => a.name === process.env.ASSET_NAME).map(a => a.id)')"
for asset in $old; do
echo " replacing $base"
curl -sS -f -X DELETE "${auth[@]}" "$api/releases/$id/assets/$asset" >/dev/null
done
echo " uploading $base"
curl -sS -f -X POST "${auth[@]}" \
-F "attachment=@$base;type=application/octet-stream" \
"$api/releases/$id/assets?name=$base" >/dev/null
done
popd >/dev/null
echo "done: $server/$GITEA_OWNER/$GITEA_REPO/releases/tag/$tag"
+301
View File
@@ -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.")
}
+111
View File
@@ -0,0 +1,111 @@
<#
.SYNOPSIS
Links the extension sources into VS Code's extensions folder for development.
.DESCRIPTION
Packaging is the wrong loop while you are actually changing code. VS Code loads any
folder under %USERPROFILE%\.vscode\extensions that has a package.json, so linking the
source there reduces the edit cycle to "npm run compile" plus a window reload.
The link is a directory junction rather than a symlink because junctions need no
elevation and no Developer Mode on Windows, and VS Code cannot tell the difference.
Run -Unlink before install.ps1: a linked source and an installed .vsix are two copies
of the same extension id, and VS Code will load one of them arbitrarily.
.PARAMETER Only
Restrict to these extension ids.
.PARAMETER Unlink
Remove the links.
.PARAMETER Compile
Run npm run compile in each extension first.
.EXAMPLE
.\scripts\dev-link.ps1 -Compile
.\scripts\dev-link.ps1 -Unlink
#>
[CmdletBinding()]
param(
[string[]] $Only,
[switch] $Unlink,
[switch] $Compile
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$config = Get-SetupConfig
$extensionsDir = Join-Path $env:USERPROFILE '.vscode\extensions'
if (-not (Test-Path $extensionsDir)) {
New-Item -ItemType Directory -Path $extensionsDir -Force | Out-Null
}
$extensions = $config.extensions
if ($Only) {
$extensions = $extensions | Where-Object { $Only -contains $_.id }
$missing = $Only | Where-Object { $config.extensions.id -notcontains $_ }
if ($missing) { throw "not in config.json: $($missing -join ', ')" }
}
foreach ($extension in $extensions) {
$source = Get-ExtensionPath $extension
$manifestPath = Join-Path $source 'package.json'
if (-not (Test-Path $manifestPath)) {
Write-Warning "no package.json in $source, skipping $($extension.id)"
continue
}
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
$linkName = "$($manifest.publisher).$($manifest.name)-$($manifest.version)"
$link = Join-Path $extensionsDir $linkName
if ($Unlink) {
if (Test-Path $link) {
# Remove-Item on a junction deletes the link, not the target -- but only
# without -Recurse, which would walk into the source and delete the files.
[System.IO.Directory]::Delete($link)
Write-Host "unlinked $linkName" -ForegroundColor Green
} else {
Write-Host "$linkName was not linked" -ForegroundColor DarkGray
}
continue
}
if ($Compile) {
Push-Location $source
try {
Write-Host "compiling $($extension.id)..." -ForegroundColor DarkGray
if (-not (Test-Path (Join-Path $source 'node_modules'))) {
& npm install --silent --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm install failed for $($extension.id)" }
}
& npm run compile --silent
if ($LASTEXITCODE -ne 0) { throw "compile failed for $($extension.id)" }
} finally { Pop-Location }
}
$main = $manifest.main -replace '^\./', ''
if (-not (Test-Path (Join-Path $source $main))) {
Write-Warning ("$($extension.id) has no built output at $main -- it will fail to " +
"activate. Pass -Compile.")
}
if (Test-Path $link) {
$item = Get-Item $link -Force
$target = $item.Target
if ($target -and @($target)[0] -eq $source) {
Write-Host "$linkName already linked" -ForegroundColor DarkGray
continue
}
throw ("$link already exists and does not point at $source. Remove it by hand " +
"(it may be a real installed copy).")
}
New-Item -ItemType Junction -Path $link -Target $source | Out-Null
Write-Host "linked $linkName -> $source" -ForegroundColor Green
}
Write-Host ''
Write-Host 'Reload the window (Developer: Reload Window) to pick this up.' -ForegroundColor Cyan
+135
View File
@@ -0,0 +1,135 @@
<#
.SYNOPSIS
Installs the extensions into VS Code, from a Gitea release or from dist/.
.DESCRIPTION
By default this downloads the .vsix assets from the -Tag release and installs them,
so a fresh machine needs nothing but VS Code and this repo -- no Node, no toolchain.
Pass -Local to install what build.ps1 produced instead.
Every install passes --force. Without it VS Code declines to reinstall a version it
already has, which would silently do nothing every time you republish the same
version number.
.PARAMETER Tag
Release tag to install from. Defaults to "latest".
.PARAMETER Local
Install from dist/ rather than downloading.
.PARAMETER Only
Restrict to these extension ids.
.PARAMETER Uninstall
Remove the extensions instead of installing them.
.EXAMPLE
.\scripts\install.ps1
.\scripts\install.ps1 -Local
.\scripts\install.ps1 -Tag v0.1.0
.\scripts\install.ps1 -Uninstall
#>
[CmdletBinding()]
param(
[string] $Tag = 'latest',
[string] $Token,
[string[]] $Only,
[switch] $Local,
[switch] $Uninstall
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$config = Get-SetupConfig
$code = Get-CodeCommand
function Select-Configured {
param([string[]] $Ids)
$chosen = $config.extensions
if ($Ids) {
$chosen = $chosen | Where-Object { $Ids -contains $_.id }
$missing = $Ids | Where-Object { $config.extensions.id -notcontains $_ }
if ($missing) { throw "not in config.json: $($missing -join ', ')" }
}
$chosen
}
if ($Uninstall) {
foreach ($extension in Select-Configured $Only) {
# The manifest's publisher is "local", so the installed id is local.<name>.
$id = "local.$($extension.id)"
Write-Host "uninstalling $id" -ForegroundColor DarkGray
& $code --uninstall-extension $id
}
Write-Host 'Reload the window for this to take effect.' -ForegroundColor Cyan
return
}
$wanted = Select-Configured $Only
$packages = @()
if ($Local) {
$dist = Get-DistDir
foreach ($extension in $wanted) {
$found = @(Get-ChildItem -Path $dist -Filter "$($extension.id)-*.vsix" |
Sort-Object Name -Descending)
if ($found.Count -eq 0) {
throw "no package for $($extension.id) in $dist. Run build.ps1 first."
}
$packages += $found[0].FullName
}
} else {
$Token = Get-GiteaToken -Token $Token
$release = Get-GiteaRelease -Tag $Tag -Token $Token
if (-not $release) {
throw ("no release tagged '$Tag' in " +
"$($config.gitea.owner)/$($config.gitea.repo). Run publish.ps1 first.")
}
$assets = @()
if ($release.PSObject.Properties.Name -contains 'assets' -and $release.assets) {
$assets = @($release.assets)
}
if ($assets.Count -eq 0) { throw "release '$Tag' has no assets" }
# Downloads go to a temp folder, not dist/, so an install never disturbs a build.
$staging = Join-Path ([System.IO.Path]::GetTempPath()) "vsix-$Tag-$PID"
New-Item -ItemType Directory -Path $staging -Force | Out-Null
foreach ($extension in $wanted) {
$match = @($assets |
Where-Object { $_.name -like "$($extension.id)-*.vsix" } |
Sort-Object name -Descending)
if ($match.Count -eq 0) {
Write-Warning "release '$Tag' has no asset for $($extension.id), skipping"
continue
}
$asset = $match[0]
$target = Join-Path $staging $asset.name
Write-Host "downloading $($asset.name)" -ForegroundColor DarkGray
Save-GiteaAsset -Url $asset.browser_download_url -Destination $target -Token $Token
$packages += $target
}
}
if ($packages.Count -eq 0) { throw 'nothing to install' }
$failed = @()
foreach ($package in $packages) {
Write-Host "installing $(Split-Path -Leaf $package)" -NoNewline
& $code --install-extension $package --force
if ($LASTEXITCODE -eq 0) {
Write-Host ' ok' -ForegroundColor Green
} else {
Write-Host ' FAILED' -ForegroundColor Red
$failed += $package
}
}
Write-Host ''
if ($failed.Count -gt 0) {
throw "$($failed.Count) of $($packages.Count) failed to install: $($failed -join ', ')"
}
Write-Host "installed $($packages.Count) extension(s). Reload the window." -ForegroundColor Cyan
+112
View File
@@ -0,0 +1,112 @@
<#
.SYNOPSIS
Uploads dist/*.vsix to a release on Gitea.
.DESCRIPTION
Creates the release for -Tag if it does not exist, then uploads each .vsix as an
asset. An asset with the same name is deleted first, so re-publishing the same tag
replaces the packages instead of piling up duplicates -- which is what makes the
default rolling "latest" tag usable as the thing install.ps1 reads.
Needs a token with write:repository scope. See Get-GiteaToken in common.ps1 for
where it is read from; it is never read from inside this repo.
.PARAMETER Tag
The release tag. Defaults to "latest", a rolling release holding current builds.
Use a version tag for a snapshot you want to keep.
.PARAMETER Build
Run build.ps1 first.
.PARAMETER Notes
Release body. Defaults to a list of the packages and their versions.
.EXAMPLE
.\scripts\publish.ps1 -Build
.\scripts\publish.ps1 -Tag v0.1.0 -Notes 'First cut of all three.'
#>
[CmdletBinding()]
param(
[string] $Tag = 'latest',
[string] $Token,
[string] $Notes,
[string] $Target,
[switch] $Build,
[switch] $Prerelease
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if ($Build) { & (Join-Path $PSScriptRoot 'build.ps1') | Out-Null }
$config = Get-SetupConfig
$Token = Get-GiteaToken -Token $Token
$dist = Get-DistDir
$repoPath = "/repos/$($config.gitea.owner)/$($config.gitea.repo)"
$packages = @(Get-ChildItem -Path $dist -Filter '*.vsix' | Sort-Object Name)
if ($packages.Count -eq 0) {
throw "no .vsix files in $dist. Run build.ps1, or pass -Build."
}
Write-Host "publishing $($packages.Count) package(s) to " -NoNewline
Write-Host "$($config.gitea.url)/$($config.gitea.owner)/$($config.gitea.repo)@$Tag" -ForegroundColor Cyan
$release = Get-GiteaRelease -Tag $Tag -Token $Token
if ($release) {
Write-Host "reusing release $($release.id)" -ForegroundColor DarkGray
} else {
if (-not $Notes) {
$lines = $packages | ForEach-Object {
$name = $_.BaseName
"- $name"
}
$Notes = @(
'VS Code extensions, packaged as VSIX.',
'',
($lines -join "`n"),
'',
'Install them with scripts/install.ps1, or by hand:',
'',
' code --install-extension <file>.vsix --force'
) -join "`n"
}
$body = @{
tag_name = $Tag
name = "VS Code extensions ($Tag)"
body = $Notes
draft = $false
prerelease = [bool] $Prerelease
}
# Omitted means Gitea creates the tag on the default branch.
if ($Target) { $body.target_commitish = $Target }
$release = Invoke-Gitea -Token $Token -Method 'POST' -Path "$repoPath/releases" -Body $body
Write-Host "created release $($release.id) for tag $Tag" -ForegroundColor DarkGray
}
$existing = @()
if ($release.PSObject.Properties.Name -contains 'assets' -and $release.assets) {
$existing = @($release.assets)
}
foreach ($package in $packages) {
$clash = $existing | Where-Object { $_.name -eq $package.Name }
foreach ($old in $clash) {
Write-Host " replacing $($package.Name)" -ForegroundColor DarkGray
Invoke-Gitea -Token $Token -Method 'DELETE' `
-Path "$repoPath/releases/$($release.id)/assets/$($old.id)" | Out-Null
}
$size = [math]::Round($package.Length / 1KB, 1)
Write-Host " uploading $($package.Name) ($size KB)" -NoNewline
$asset = Send-GiteaAsset -ReleaseId $release.id -FilePath $package.FullName -Token $Token
Write-Host " ok" -ForegroundColor Green
Write-Verbose " $($asset.browser_download_url)"
}
Write-Host ''
Write-Host "done: $($config.gitea.url)/$($config.gitea.owner)/$($config.gitea.repo)/releases/tag/$Tag" -ForegroundColor Cyan
+192
View File
@@ -0,0 +1,192 @@
<#
.SYNOPSIS
Moves VS Code user settings between this repo and the machine.
.DESCRIPTION
-Push copies the machine's settings into User/ so you can commit them.
-Pull copies User/ onto the machine, backing up whatever was there.
-Diff says what differs without touching anything.
This copies rather than symlinking on purpose. A symlink at
%APPDATA%\Code\User\settings.json is fragile: an editor that saves atomically
(write a temp file, rename it over the target) replaces the link with a regular
file, and the sync silently stops without anything looking wrong. Copying is
explicit -- you can see in git what changed and when.
-Push also records your marketplace extensions in User/extensions.txt, and -Pull
installs any that are missing. Ids starting "local." are skipped: those are the
extensions in this repo, and install.ps1 gets them from the Gitea release.
.EXAMPLE
.\scripts\settings.ps1 -Diff
.\scripts\settings.ps1 -Push
.\scripts\settings.ps1 -Pull
#>
[CmdletBinding(DefaultParameterSetName = 'Diff')]
param(
[Parameter(ParameterSetName = 'Push', Mandatory)] [switch] $Push,
[Parameter(ParameterSetName = 'Pull', Mandatory)] [switch] $Pull,
[Parameter(ParameterSetName = 'Diff')] [switch] $Diff,
[switch] $NoExtensions
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$repoUser = Join-Path (Get-RepoRoot) 'User'
$codeUser = Join-Path $env:APPDATA 'Code\User'
if (-not (Test-Path $codeUser)) {
throw ("VS Code's user folder was not found at $codeUser. If you use Insiders or a " +
"portable install, point APPDATA at it or copy by hand.")
}
# Only the things worth version-controlling. Everything else in User/ is state:
# globalStorage, workspaceStorage, History, sync, logs.
$items = @(
@{ Name = 'settings.json'; Kind = 'File' },
@{ Name = 'keybindings.json'; Kind = 'File' },
@{ Name = 'tasks.json'; Kind = 'File' },
@{ Name = 'snippets'; Kind = 'Directory' }
)
function Get-Fingerprint {
param([string] $Path, [string] $Kind)
if (-not (Test-Path $Path)) { return $null }
if ($Kind -eq 'File') { return (Get-FileHash $Path -Algorithm SHA256).Hash }
$parts = Get-ChildItem -Path $Path -Recurse -File | Sort-Object FullName | ForEach-Object {
"$($_.FullName.Substring($Path.Length)):$((Get-FileHash $_.FullName -Algorithm SHA256).Hash)"
}
if (-not $parts) { return $null }
$joined = $parts -join '|'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($joined)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
([BitConverter]::ToString($sha.ComputeHash($bytes))) -replace '-', ''
} finally { $sha.Dispose() }
}
function Copy-Item2 {
param([string] $From, [string] $To, [string] $Kind)
$parent = Split-Path -Parent $To
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
if ($Kind -eq 'File') {
Copy-Item -Path $From -Destination $To -Force
} else {
if (Test-Path $To) { Remove-Item -Path $To -Recurse -Force }
Copy-Item -Path $From -Destination $To -Recurse -Force
}
}
function Backup-Existing {
param([string] $Path, [string] $Kind)
if (-not (Test-Path $Path)) { return }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backup = "$Path.backup-$stamp"
if ($Kind -eq 'File') {
Copy-Item -Path $Path -Destination $backup -Force
} else {
Copy-Item -Path $Path -Destination $backup -Recurse -Force
}
Write-Host " backed up to $(Split-Path -Leaf $backup)" -ForegroundColor DarkGray
}
$mode = $PSCmdlet.ParameterSetName
Write-Host "repo: $repoUser"
Write-Host "code: $codeUser"
Write-Host ''
foreach ($item in $items) {
$inRepo = Join-Path $repoUser $item.Name
$onDisk = Join-Path $codeUser $item.Name
$repoPrint = Get-Fingerprint -Path $inRepo -Kind $item.Kind
$diskPrint = Get-Fingerprint -Path $onDisk -Kind $item.Kind
if (-not $repoPrint -and -not $diskPrint) {
Write-Host " $($item.Name): absent both sides" -ForegroundColor DarkGray
continue
}
if ($repoPrint -eq $diskPrint) {
Write-Host " $($item.Name): identical" -ForegroundColor DarkGray
continue
}
switch ($mode) {
'Push' {
if (-not $diskPrint) {
Write-Host " $($item.Name): not on this machine, leaving the repo copy alone" `
-ForegroundColor Yellow
continue
}
Copy-Item2 -From $onDisk -To $inRepo -Kind $item.Kind
Write-Host " $($item.Name): machine -> repo" -ForegroundColor Green
}
'Pull' {
if (-not $repoPrint) {
Write-Host " $($item.Name): not in the repo, leaving the machine alone" `
-ForegroundColor Yellow
continue
}
Backup-Existing -Path $onDisk -Kind $item.Kind
Copy-Item2 -From $inRepo -To $onDisk -Kind $item.Kind
Write-Host " $($item.Name): repo -> machine" -ForegroundColor Green
}
default {
$where = if (-not $repoPrint) { 'only on this machine' }
elseif (-not $diskPrint) { 'only in the repo' }
else { 'differs' }
Write-Host " $($item.Name): $where" -ForegroundColor Yellow
}
}
}
if ($NoExtensions) { return }
Write-Host ''
$listPath = Join-Path $repoUser 'extensions.txt'
$code = Get-CodeCommand
if ($mode -eq 'Push') {
$installed = @(& $code --list-extensions) |
Where-Object { $_ -and $_ -notlike 'local.*' } |
Sort-Object
if (-not (Test-Path $repoUser)) { New-Item -ItemType Directory -Path $repoUser | Out-Null }
Set-Content -Path $listPath -Value $installed -Encoding UTF8
Write-Host " extensions.txt: recorded $($installed.Count) marketplace extension(s)" `
-ForegroundColor Green
return
}
if (-not (Test-Path $listPath)) {
Write-Host ' extensions.txt: not in the repo yet, run -Push on your main machine' `
-ForegroundColor Yellow
return
}
$wanted = @(Get-Content $listPath | Where-Object { $_ -and -not $_.StartsWith('#') })
$present = @(& $code --list-extensions)
$missing = @($wanted | Where-Object { $present -notcontains $_ })
if ($mode -eq 'Diff') {
if ($missing.Count -eq 0) {
Write-Host " extensions.txt: all $($wanted.Count) present" -ForegroundColor DarkGray
} else {
Write-Host " extensions.txt: $($missing.Count) missing: $($missing -join ', ')" `
-ForegroundColor Yellow
}
return
}
if ($missing.Count -eq 0) {
Write-Host " extensions.txt: all $($wanted.Count) already installed" -ForegroundColor DarkGray
return
}
foreach ($id in $missing) {
Write-Host " installing $id" -ForegroundColor DarkGray
& $code --install-extension $id
}
Write-Host ''
Write-Host 'Reload the window to pick up new settings and extensions.' -ForegroundColor Cyan