Build, publish to Gitea releases, and install the three extensions
Answers two things: how to install these without a marketplace, and how to keep settings in sync when VS Code's Settings Sync cannot be pointed at Gitea. - build.ps1 packages each extension into dist/ - publish.ps1 creates a release and uploads them, replacing same-named assets so a rolling "latest" tag stays clean - install.ps1 downloads a release's assets and installs them, or -Local from dist/ - settings.ps1 copies User/ between the repo and the machine, and installs the marketplace extensions recorded in extensions.txt - dev-link.ps1 junctions the sources into ~/.vscode/extensions for development - ci-release.sh plus a Gitea Actions workflow do the build and publish on a tag push Settings are copied, not symlinked: an atomic save replaces a symlink with a regular file and the sync stops without looking broken. vsce/ pins vsce and undici so packaging works on Node 18, which current vsce does not support. test/run.ps1 drives the real scripts against a stub of the Gitea release API. It caught the multipart Content-Disposition being unquoted on .NET Framework, and Invoke-WebRequest dropping the Authorization header across a redirect.
This commit is contained in:
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.token
|
||||||
|
*.log
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
Generated
+2260
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user