# 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-solution-launcher` | Startup project and solution configuration (`Test \| x64`) in the status bar; build and debug that pass the platform DotRush drops | ## The short version ```powershell # once per machine, on the machine that builds $env:GITEA_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 # the same on Linux or macOS, no PowerShell needed scripts/install.sh scripts/settings.sh --pull ``` 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 `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 ` 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 | | [`install.sh`](scripts/install.sh) | The bash equivalent of `install.ps1`: `--tag`, `--local`, `--only`, `--uninstall`, `--token` | | [`settings.sh`](scripts/settings.sh) | The bash equivalent of `settings.ps1`: `--push`, `--pull`, `--diff`, `--no-extensions` | Every PowerShell script takes `-?` for its full help, the bash ones `--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 ` 2. `$env:GITEA_TOKEN` 3. `~/.gitea-token` (`%USERPROFILE%\.gitea-token` on Windows) Create one at `/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. - **Linux and macOS.** `install.sh` and `settings.sh` are bash twins of the PowerShell scripts and need only bash, curl and one of jq, node or python3 for JSON. The `.ps1` scripts also run there under PowerShell 7 (`pwsh`). Both find VS Code's user folder at `~/.config/Code/User` (`~/Library/Application Support/Code/User` on macOS) and the CLI in the usual apt, snap, flatpak and Homebrew locations. Set `VSCODE_USER_DIR` for Insiders or a portable install. `dev-link.ps1` makes a symlink instead of a junction. `ci-release.sh` needs only bash.