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
+51
View File
@@ -0,0 +1,51 @@
# Packages the extensions and publishes them to a Gitea release.
#
# Needs a registered act_runner on the instance, and a GITEA_TOKEN secret with
# write:repository plus read access to the extension repos. Gitea fetches the
# actions/* actions from github.com unless the instance overrides
# [actions] DEFAULT_ACTIONS_URL, so a locked-down instance may need those mirrored.
#
# ci-release.sh needs only bash, git, node and curl -- everything the runner image
# already has once setup-node has run.
#
# The runner uses Node 20, which is also why CI is the easy way to build these:
# vsce does not run on Node 18. See the README.
name: Release extensions
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish to'
required: false
default: 'latest'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Package and publish
env:
GITEA_SERVER: ${{ github.server_url }}
GITEA_OWNER: ${{ github.repository_owner }}
GITEA_REPO: ${{ github.event.repository.name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# A tag push publishes to that tag; a manual run to whatever was asked for.
TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.tag }}
run: bash scripts/ci-release.sh
- uses: actions/upload-artifact@v3
if: always()
with:
name: vsix
path: dist/*.vsix
+4
View File
@@ -0,0 +1,4 @@
dist/
node_modules/
.token
*.log
+180
View File
@@ -0,0 +1,180 @@
# VS Code setup
Builds three local VS Code extensions, publishes them to a **Gitea release**, installs
them from there, and keeps user settings in git.
| Extension | What it does |
| --- | --- |
| `colored-references` | Find All References in a syntax-highlighted virtual document, plus a sortable results panel |
| `vertical-tabs` | A vertical list of open tabs, colour-coded by project, with pinning |
| `dotnet-hot-reload` | A hot reload button in the debug toolbar, driving `dotnet watch` |
## The short version
```powershell
# once per machine, on the machine that builds
$env:GITEA_TOKEN = '<token>'
.\scripts\publish.ps1 -Build # package all three, upload to the "latest" release
# on any machine, including a fresh one
.\scripts\install.ps1 # download from the release and install
.\scripts\settings.ps1 -Pull # apply settings, keybindings, marketplace extensions
```
Set the server first — either in [`config.json`](config.json) or with `GITEA_URL`,
`GITEA_OWNER` and `GITEA_REPO`. The repo named there is where releases go; it does not
have to be this repo.
## Why not Settings Sync
**VS Code's built-in Settings Sync cannot be pointed at Gitea.** It signs in with a
Microsoft or GitHub account and talks to Microsoft's own sync service; there is no
setting for a different backend, self-hosted or otherwise. The old
Gist-based *Settings Sync* extension is out too, because Gitea has no gists.
So settings live in [`User/`](User/) as ordinary files, and
[`scripts/settings.ps1`](scripts/settings.ps1) copies them in either direction. That is
less magic than Settings Sync and more legible: a diff shows what changed.
It **copies rather than symlinks** 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 stops without anything looking broken.
Extensions from the marketplace are handled separately: `settings.ps1 -Push` records
them in `User/extensions.txt`, `-Pull` installs the missing ones. Ids starting `local.`
are skipped, because those are the three above and they come from the release.
## Scripts
| Script | Does |
| --- | --- |
| [`build.ps1`](scripts/build.ps1) | Packages each extension into `dist/*.vsix`. `-Clone` fetches missing sources from Gitea, `-Pull` updates them, `-Only <id>` narrows it |
| [`publish.ps1`](scripts/publish.ps1) | Creates the release if needed and uploads `dist/*.vsix`. `-Build` builds first, `-Tag` picks the tag |
| [`install.ps1`](scripts/install.ps1) | Downloads a release's assets and installs them. `-Local` installs from `dist/` instead, `-Uninstall` removes them |
| [`settings.ps1`](scripts/settings.ps1) | `-Push` machine to repo, `-Pull` repo to machine, `-Diff` compares |
| [`dev-link.ps1`](scripts/dev-link.ps1) | Junctions the sources into `~/.vscode/extensions` for development. `-Unlink` undoes it |
| [`ci-release.sh`](scripts/ci-release.sh) | The bash equivalent of `publish.ps1`, for the Gitea Actions runner. `SKIP_PACKAGE=1` uploads `dist/` without rebuilding |
Every script takes `-?` for its full help.
### Tags
`publish.ps1` defaults to a rolling **`latest`** release: publishing again replaces the
assets in place rather than adding duplicates, so `install.ps1` with no arguments always
gets current builds. Use `-Tag v0.2.0` for a snapshot you want to keep, and
`install.ps1 -Tag v0.2.0` to go back to it.
The tag itself is created on the default branch the first time. For a release that only
carries binaries the commit it points at does not mean much, but it is why `latest`
keeps pointing at an old commit — the assets are what move.
## Tokens
Never in this repo. `publish.ps1` and `install.ps1` read, in order:
1. `-Token <value>`
2. `$env:GITEA_TOKEN`
3. `%USERPROFILE%\.gitea-token`
Create one at `<gitea>/user/settings/applications` with **`write:repository`** scope.
`install.ps1` only reads, so a `read:repository` token is enough there — and if the
release repo is public it needs no token at all for the download, though the API call
that finds the release still wants one on most instances.
## Building on Node 18
`vsce` needs **Node 20 or newer**. On Node 18 it fails with
`ReferenceError: File is not defined`, from a transitive `undici`, and pinning an older
`vsce` does not help because `undici` still resolves to a current version.
[`vsce/package.json`](vsce/package.json) works around it by pinning both, and
`build.ps1` installs into that folder on first run. If you upgrade Node to 20+ you can
delete `vsce/` and use `npx @vscode/vsce package` directly.
Upgrading Node is the better fix. The workaround exists so a machine stuck on 18 is not
blocked.
## Automating it
[`.gitea/workflows/release.yml`](.gitea/workflows/release.yml) builds and publishes on a
`v*` tag push, or on demand. It needs:
- a registered `act_runner` on the instance
- a `GITEA_TOKEN` secret with `write:repository`, and read access to the extension repos
- the `actions/checkout` and `actions/setup-node` actions reachable — Gitea pulls those
from github.com unless the instance sets `[actions] DEFAULT_ACTIONS_URL`
The runner uses Node 20, so CI sidesteps the problem above entirely.
`ci-release.sh` needs only bash, git, node and curl — JSON goes through `node`, not `jq`,
since node is required anyway and `jq` frequently is not.
## Why not `tea`
Gitea's CLI ([`tea`](https://gitea.com/gitea/tea)) can do this — `tea release create`
and `tea release assets create` cover the upload. It is not used here because these
scripts then need nothing but PowerShell and `code`: no Go binary to install per
machine, no `tea login add` step, no second place a token lives. Downloading assets on
the install side is also plainer over the API than through `tea`.
If you already have `tea` set up, `tea release create --tag latest --asset dist/*.vsix`
is a fine substitute for `publish.ps1`.
## Layout
```
config.json Gitea server + the extensions and where they live
User/ settings.json, keybindings.json, snippets/, extensions.txt
dist/ built .vsix files (ignored)
scripts/ the scripts above
vsce/ pinned vsce, for Node 18 (node_modules ignored)
.gitea/workflows/ the release workflow
```
`config.json` paths are relative to this repo, so it expects the extension folders as
siblings. Move them and it is one edit.
## Tests
```powershell
.\test\run.ps1
```
Runs the real scripts against [`test/fake-gitea.js`](test/fake-gitea.js), a stub of the
release endpoints, with a stub `code` on `PATH` so nothing is installed into the editor.
It checks the things that fail quietly:
- the `.vsix` survives the upload **byte for byte** — it is a zip, so any text-encoding
step in the multipart body would corrupt it and still look like a success
- the token reaches the API, and survives the redirect on the download
- republishing a tag replaces its assets instead of accumulating duplicates
- every install passes `--force`
- `build.ps1` and `install.ps1 -Local` work with no Gitea configured at all
- `ci-release.sh` publishes the same set as `publish.ps1`
Two bugs came out of writing it. `MultipartFormDataContent.Add(content, name, fileName)`
emits an unquoted `name=attachment` on .NET Framework, which Gitea's Go parser accepts
but nothing guarantees — the header is now set explicitly. And `Invoke-WebRequest` drops
the `Authorization` header when it follows a redirect, so downloading a private repo's
asset returned 401; `Save-GiteaAsset` follows redirects itself, re-sending the token only
while the host does not change.
What it does not cover: a real Gitea instance, and whether VS Code loads the packages
once installed. Both were checked by hand.
## Known limitations
- **No auto-update.** VS Code only offers updates for marketplace extensions, so a new
release does not notify you. Re-run `install.ps1`.
- **`--force` on every install**, because VS Code otherwise declines to reinstall a
version it already has, which would silently do nothing whenever you republish
without bumping the version.
- **A private extension gallery** — pointing VS Code's `product.json` at your own
gallery so updates work normally — is possible but gets overwritten by VS Code
updates. Not worth it for three extensions.
- **`dev-link.ps1` and `install.ps1` conflict.** A junctioned source and an installed
`.vsix` are two copies of the same extension id; VS Code loads one arbitrarily.
`dev-link.ps1 -Unlink` before installing.
- **Windows-only paths.** The scripts assume `%APPDATA%\Code\User` and
`%USERPROFILE%\.vscode\extensions`. `ci-release.sh` is the portable half.
+26
View File
@@ -0,0 +1,26 @@
{
"//": "Fill in your Gitea server. GITEA_URL / GITEA_OWNER / GITEA_REPO override these.",
"gitea": {
"url": "https://gitea.example.com",
"owner": "max",
"repo": "vscode-extensions"
},
"//extensions": "path is relative to this repo; repo is the Gitea repo to clone if path is missing.",
"extensions": [
{
"id": "colored-references",
"path": "../colored-references-src/colored-references",
"repo": "colored-references"
},
{
"id": "vertical-tabs",
"path": "../vertical-tabs",
"repo": "vertical-tabs"
},
{
"id": "dotnet-hot-reload",
"path": "../dotnet-hot-reload",
"repo": "dotnet-hot-reload"
}
]
}
+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
+215
View File
@@ -0,0 +1,215 @@
// A stub of the parts of the Gitea API that publish.ps1 and install.ps1 use.
//
// The point is to prove the real scripts work: that the token header is sent, that the
// multipart upload delivers the .vsix byte-for-byte (a zip, so any encoding step would
// corrupt it), that re-publishing replaces assets instead of duplicating them, and that
// the download survives a redirect with the auth header intact.
const http = require('http');
const crypto = require('crypto');
const PORT = Number(process.env.PORT || 5602);
const TOKEN = 'TESTTOKEN';
let nextId = 100;
const releases = []; // { id, tag_name, name, body, assets: [] }
const blobs = new Map(); // uuid -> Buffer
const log = [];
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex').toUpperCase();
}
function authorized(request) {
return request.headers.authorization === `token ${TOKEN}`;
}
function send(response, status, payload) {
const body = payload === undefined ? '' : JSON.stringify(payload);
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(body);
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => resolve(Buffer.concat(chunks)));
request.on('error', reject);
});
}
/** The one file out of a multipart/form-data body, as raw bytes. */
function parseMultipart(buffer, contentType) {
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
if (!match) { return null; }
const boundary = Buffer.from(`--${match[1] || match[2]}`);
const parts = [];
let at = buffer.indexOf(boundary);
while (at >= 0) {
const from = at + boundary.length;
const next = buffer.indexOf(boundary, from);
if (next < 0) { break; }
parts.push(buffer.slice(from, next));
at = next;
}
for (const part of parts) {
const split = part.indexOf('\r\n\r\n');
if (split < 0) { continue; }
const headers = part.slice(0, split).toString('latin1');
// Trailing CRLF belongs to the delimiter, not the content.
let content = part.slice(split + 4);
if (content.slice(-2).toString('latin1') === '\r\n') {
content = content.slice(0, -2);
}
// Values may be unquoted: .NET Framework's MultipartFormDataContent writes
// name=attachment, and Go's mime/multipart (what Gitea uses) accepts both.
const name = /\bname=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const filename = /\bfilename=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const fieldName = name ? (name[1] !== undefined ? name[1] : name[2]) : null;
if (fieldName === 'attachment') {
return {
content,
filename: filename ? (filename[1] !== undefined ? filename[1] : filename[2]) : null,
headers: headers.trim(),
};
}
}
return null;
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
const path = url.pathname;
log.push(`${request.method} ${request.url}`);
// Serving an attachment: what browser_download_url points at.
let hit = /^\/attachments\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
// Redirect once, so the client has to carry the auth header across it.
response.writeHead(302, { Location: `/files/${hit[1]}` });
return response.end();
}
hit = /^\/files\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
if (!authorized(request)) {
log.push(' !! download had no token');
return send(response, 401, { message: 'token required on download' });
}
const blob = blobs.get(hit[1]);
if (!blob) { return send(response, 404, { message: 'no such attachment' }); }
response.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': blob.length,
});
return response.end(blob);
}
// Test-only: lets the harness assert on what the server actually received.
if (path === '/__state') {
return send(response, 200, {
log,
releases: releases.map(release => ({
id: release.id,
tag_name: release.tag_name,
name: release.name,
assets: release.assets.map(asset => ({
id: asset.id,
name: asset.name,
size: asset.size,
sha256: asset.sha256,
uploadFilename: asset.uploadFilename,
})),
})),
});
}
if (!authorized(request)) {
log.push(' !! missing or wrong token');
return send(response, 401, { message: 'token required' });
}
const api = /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/releases(.*)$/.exec(path);
if (!api) { return send(response, 404, { message: `unhandled ${path}` }); }
const rest = api[3];
// GET /releases/tags/:tag
hit = /^\/tags\/(.+)$/.exec(rest);
if (hit && request.method === 'GET') {
const tag = decodeURIComponent(hit[1]);
const release = releases.find(candidate => candidate.tag_name === tag);
if (!release) { return send(response, 404, { message: 'release not found' }); }
return send(response, 200, release);
}
// POST /releases
if (rest === '' && request.method === 'POST') {
const body = JSON.parse((await readBody(request)).toString('utf8') || '{}');
if (!body.tag_name) { return send(response, 422, { message: 'tag_name required' }); }
const release = {
id: nextId++,
tag_name: body.tag_name,
name: body.name || body.tag_name,
body: body.body || '',
draft: !!body.draft,
prerelease: !!body.prerelease,
target_commitish: body.target_commitish || 'main',
assets: [],
};
releases.push(release);
return send(response, 201, release);
}
// POST /releases/:id/assets?name=...
hit = /^\/(\d+)\/assets$/.exec(rest);
if (hit && request.method === 'POST') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const raw = await readBody(request);
const part = parseMultipart(raw, request.headers['content-type']);
if (!part) {
const preview = raw.slice(0, 400).toString('latin1').replace(/\r\n/g, '\\r\\n');
log.push(` !! no attachment field. content-type=${request.headers['content-type']}`);
log.push(` !! body starts: ${preview}`);
return send(response, 400, { message: 'expected multipart with an attachment field' });
}
log.push(` disposition: ${part.headers.replace(/\r\n/g, ' | ')}`);
const name = url.searchParams.get('name') || part.filename || 'unnamed';
const uuid = crypto.randomBytes(8).toString('hex');
blobs.set(uuid, part.content);
const asset = {
id: nextId++,
name,
size: part.content.length,
sha256: sha256(part.content),
uploadFilename: part.filename,
browser_download_url: `http://127.0.0.1:${PORT}/attachments/${uuid}`,
};
release.assets.push(asset);
return send(response, 201, asset);
}
// DELETE /releases/:id/assets/:assetId
hit = /^\/(\d+)\/assets\/(\d+)$/.exec(rest);
if (hit && request.method === 'DELETE') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const before = release.assets.length;
release.assets = release.assets.filter(asset => asset.id !== Number(hit[2]));
if (release.assets.length === before) {
return send(response, 404, { message: 'no such asset' });
}
response.writeHead(204);
return response.end();
}
return send(response, 404, { message: `unhandled ${request.method} ${path}` });
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`fake gitea on http://127.0.0.1:${PORT} (token ${TOKEN})`);
});
+204
View File
@@ -0,0 +1,204 @@
<#
.SYNOPSIS
Exercises publish.ps1, install.ps1 and ci-release.sh against a stub Gitea.
.DESCRIPTION
Runs the real scripts, pointed at test/fake-gitea.js instead of a server, with a
stub "code" on PATH so nothing is installed into the editor. What it checks:
- the token reaches the API, and the download after the redirect
- the .vsix arrives byte-for-byte (it is a zip, so any text encoding step shows up)
- republishing a tag replaces assets instead of duplicating them
- install passes --force, without which VS Code silently skips a same-version
reinstall
- build.ps1 and install.ps1 -Local work with no Gitea configured
- ci-release.sh agrees with publish.ps1
Needs dist/*.vsix, so it runs build.ps1 first unless -SkipBuild.
.EXAMPLE
.\test\run.ps1
.\test\run.ps1 -SkipBuild
#>
[CmdletBinding()]
param(
[int] $Port = 5602,
[switch] $SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$scripts = Join-Path $root 'scripts'
$dist = Join-Path $root 'dist'
$base = "http://127.0.0.1:$Port"
$failures = New-Object System.Collections.ArrayList
function Assert-That {
param([string] $What, [bool] $Condition, [string] $Detail = '')
if ($Condition) {
Write-Host " PASS $What" -ForegroundColor Green
} else {
Write-Host " FAIL $What" -ForegroundColor Red
if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray }
[void] $failures.Add($What)
}
}
function Get-State { Invoke-RestMethod -Uri "$base/__state" -UseBasicParsing }
# A stub code CLI, so an install never touches the real editor. It has to be found by
# Get-CodeCommand, which looks at PATH first.
$stubDir = Join-Path ([System.IO.Path]::GetTempPath()) "vscode-setup-test-$PID"
New-Item -ItemType Directory -Path $stubDir -Force | Out-Null
$callLog = Join-Path $stubDir 'calls.log'
Set-Content -Path (Join-Path $stubDir 'code.cmd') -Encoding ASCII -Value @(
'@echo off',
"echo %* >> `"$callLog`"",
'exit /b 0'
)
$server = $null
$savedPath = $env:PATH
try {
if (-not $SkipBuild) {
Write-Host 'building...' -ForegroundColor Cyan
& (Join-Path $scripts 'build.ps1') | Out-Null
}
$packages = @(Get-ChildItem -Path $dist -Filter '*.vsix')
if ($packages.Count -eq 0) { throw "no .vsix in $dist" }
Write-Host "starting the stub on $base" -ForegroundColor Cyan
# Set before spawning: the child inherits the environment as it is at that moment.
$env:PORT = "$Port"
$server = Start-Process -FilePath 'node' `
-ArgumentList (Join-Path $PSScriptRoot 'fake-gitea.js') `
-WindowStyle Hidden -PassThru
$up = $false
foreach ($attempt in 1..20) {
try { Get-State | Out-Null; $up = $true; break } catch { Start-Sleep -Milliseconds 250 }
}
if (-not $up) { throw "the stub did not come up on $base" }
$env:GITEA_URL = $base
$env:GITEA_OWNER = 'max'
$env:GITEA_REPO = 'vscode-extensions'
$env:GITEA_TOKEN = 'TESTTOKEN'
$env:PATH = "$stubDir;$savedPath"
Write-Host ''
Write-Host 'publish.ps1' -ForegroundColor Cyan
& (Join-Path $scripts 'publish.ps1') -Tag 'test' | Out-Null
$state = Get-State
$release = $state.releases | Where-Object { $_.tag_name -eq 'test' }
Assert-That 'the release is created' ($null -ne $release)
Assert-That "all $($packages.Count) packages are uploaded" `
($release.assets.Count -eq $packages.Count) "got $($release.assets.Count)"
$intact = $true
foreach ($asset in $release.assets) {
$local = Join-Path $dist $asset.name
if ((Get-FileHash $local -Algorithm SHA256).Hash -ne $asset.sha256) { $intact = $false }
}
Assert-That 'the uploaded bytes match dist/ exactly' $intact
$quoted = @($state.log | Where-Object { $_ -like '*disposition*' })
Assert-That 'the multipart field is name="attachment"' `
(($quoted.Count -gt 0) -and ($quoted[-1] -like '*name="attachment"*')) `
($(if ($quoted.Count) { $quoted[-1] } else { 'no disposition logged' }))
Write-Host ''
Write-Host 'publish.ps1 again' -ForegroundColor Cyan
& (Join-Path $scripts 'publish.ps1') -Tag 'test' | Out-Null
$release = (Get-State).releases | Where-Object { $_.tag_name -eq 'test' }
Assert-That 'republishing replaces rather than duplicates' `
($release.assets.Count -eq $packages.Count) "got $($release.assets.Count)"
Write-Host ''
Write-Host 'install.ps1' -ForegroundColor Cyan
Remove-Item $callLog -ErrorAction SilentlyContinue
& (Join-Path $scripts 'install.ps1') -Tag 'test' | Out-Null
$calls = @(Get-Content $callLog -ErrorAction SilentlyContinue)
Assert-That "code was called once per extension" ($calls.Count -eq $packages.Count) `
"got $($calls.Count)"
Assert-That 'every install passes --force' `
(($calls.Count -gt 0) -and -not ($calls | Where-Object { $_ -notmatch '--force' }))
$downloadedOk = $calls.Count -gt 0
foreach ($call in $calls) {
$path = ($call -replace '^--install-extension\s+', '' -replace '\s+--force\s*$', '').Trim()
$local = Join-Path $dist (Split-Path -Leaf $path)
if (-not (Test-Path $path) -or
(Get-FileHash $path -Algorithm SHA256).Hash -ne
(Get-FileHash $local -Algorithm SHA256).Hash) { $downloadedOk = $false }
}
Assert-That 'the downloaded files match dist/ (token survived the redirect)' $downloadedOk
Write-Host ''
Write-Host 'no Gitea configured' -ForegroundColor Cyan
$env:GITEA_URL = ''; $env:GITEA_OWNER = ''; $env:GITEA_REPO = ''
Remove-Item $callLog -ErrorAction SilentlyContinue
$localWorked = $true
try { & (Join-Path $scripts 'install.ps1') -Local | Out-Null } catch { $localWorked = $false }
Assert-That 'install.ps1 -Local needs no Gitea config' $localWorked
$env:GITEA_URL = $base; $env:GITEA_OWNER = 'max'; $env:GITEA_REPO = 'vscode-extensions'
# Git's bash, not the one on PATH: on Windows that is usually
# System32\bash.exe, the WSL launcher, which sees D:\ as /mnt/d and so cannot open
# the script by the path we have.
$bash = @(
'C:\Program Files\Git\bin\bash.exe',
'C:\Program Files (x86)\Git\bin\bash.exe',
(Join-Path $env:LOCALAPPDATA 'Programs\Git\bin\bash.exe')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $bash) {
$onPath = Get-Command bash -ErrorAction SilentlyContinue
if ($onPath -and $onPath.Source -notlike "$env:WINDIR*") { $bash = $onPath.Source }
}
if ($bash) {
Write-Host ''
Write-Host 'ci-release.sh' -ForegroundColor Cyan
$env:GITEA_SERVER = $base
$env:TAG = 'test-ci'
$env:SKIP_PACKAGE = '1'
# Forward slashes too: bash reads the backslashes in a Windows path as escapes,
# so D:\a\b arrives as D:ab.
$shPath = (Join-Path $scripts 'ci-release.sh') -replace '\\', '/'
& $bash $shPath 2>&1 | Out-Null
$ci = (Get-State).releases | Where-Object { $_.tag_name -eq 'test-ci' }
Assert-That 'ci-release.sh publishes the same set' `
(($null -ne $ci) -and ($ci.assets.Count -eq $packages.Count))
$ciIntact = $null -ne $ci
if ($ci) {
foreach ($asset in $ci.assets) {
$local = Join-Path $dist $asset.name
if ((Get-FileHash $local -Algorithm SHA256).Hash -ne $asset.sha256) {
$ciIntact = $false
}
}
}
Assert-That 'ci-release.sh uploads intact bytes' $ciIntact
Remove-Item Env:\SKIP_PACKAGE, Env:\TAG, Env:\GITEA_SERVER -ErrorAction SilentlyContinue
} else {
Write-Host ' SKIP ci-release.sh (no bash on PATH)' -ForegroundColor Yellow
}
} finally {
$env:PATH = $savedPath
Remove-Item Env:\GITEA_URL, Env:\GITEA_OWNER, Env:\GITEA_REPO, Env:\GITEA_TOKEN, Env:\PORT `
-ErrorAction SilentlyContinue
if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force }
Remove-Item $stubDir -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ''
if ($failures.Count -eq 0) {
Write-Host 'all checks passed' -ForegroundColor Green
exit 0
}
Write-Host "$($failures.Count) check(s) failed:" -ForegroundColor Red
$failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
+2260
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "vsce-host",
"private": true,
"version": "1.0.0",
"description": "Pins vsce to a version that runs on Node 18. See ../README.md.",
"devDependencies": {
"@vscode/vsce": "2.32.0"
},
"overrides": {
"undici": "6.21.3"
}
}