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,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
|
||||
Reference in New Issue
Block a user