@workerstack/system 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Containers and browsers: docker, chromium, lightpanda, playwright.
3
+ */
4
+
5
+ import { binary, LOCAL_BIN, localBinEnv, pick } from "./recipes.ts"
6
+ import type { BuiltinDep } from "./types.ts"
7
+
8
+ /**
9
+ * Prints the version only when Playwright's Chromium is on disk. `bunx playwright@<v> --version` alone
10
+ * always succeeds (bunx fetches the CLI), which would make `install` skip the browser download.
11
+ */
12
+ const PLAYWRIGHT_CHECK =
13
+ "bun -e \"const r=Bun.spawnSync(['bunx','playwright@{{version}}','install','--dry-run','chromium']);" +
14
+ "const m=r.stdout.toString().match(/Install location:\\s*(.+)/);" +
15
+ "if(m&&require('fs').existsSync(m[1].trim()))console.log('{{version}}');else process.exit(1)\""
16
+
17
+ export const BROWSERS = {
18
+ chromium: {
19
+ description: "Chromium, the browser rail fallback (Linux only: brew and choco ship no usable build)",
20
+ env: localBinEnv,
21
+ os: () => ({
22
+ "linux-apt": "sudo apt-get install -y chromium || sudo snap install chromium",
23
+ /* Fedora names the binary chromium-browser; link the name check and the browser rail use */
24
+ "linux-dnf": `sudo dnf install -y chromium && mkdir -p "${LOCAL_BIN}" && ln -sf /usr/bin/chromium-browser "${LOCAL_BIN}/chromium"`,
25
+ "linux-pacman": "sudo pacman -S --noconfirm chromium",
26
+ macos: false,
27
+ windows: false,
28
+ }),
29
+ versions: "latest",
30
+ },
31
+ docker: {
32
+ dependsOn: ["brew", "choco"],
33
+ description: "Container engine with its daemon (Docker Engine on Linux, Docker Desktop elsewhere)",
34
+ os: () => ({
35
+ "linux-apt": 'curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker "$USER"',
36
+ "linux-dnf": 'curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker "$USER"',
37
+ "linux-pacman":
38
+ 'sudo pacman -S --noconfirm docker docker-buildx docker-compose && sudo systemctl enable --now docker.service && sudo usermod -aG docker "$USER"',
39
+ macos: "brew install --cask docker-desktop",
40
+ windows: "choco install docker-desktop -y",
41
+ }),
42
+ versions: "latest",
43
+ },
44
+ lightpanda: {
45
+ check: { command: "lightpanda version" },
46
+ description: "Lightpanda, the default headless browser rail (nightly; no Windows build)",
47
+ env: localBinEnv,
48
+ os: ({ arch }) => {
49
+ const base = "https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda"
50
+ const cpu = pick(arch, { arm64: "aarch64", x64: "x86_64" })
51
+ const linux = binary(`${base}-${cpu}-linux`, "lightpanda")
52
+ return {
53
+ "linux-apt": linux,
54
+ "linux-dnf": linux,
55
+ "linux-pacman": linux,
56
+ macos: binary(`${base}-${cpu}-macos`, "lightpanda"),
57
+ windows: false,
58
+ }
59
+ },
60
+ versions: "latest",
61
+ },
62
+ playwright: {
63
+ check: { command: PLAYWRIGHT_CHECK },
64
+ dependsOn: ["bun"],
65
+ description: "Playwright's Chromium for browser tests (version = your @playwright/test)",
66
+ os: ({ version }) => {
67
+ const browserOnly = `bunx playwright@${version} install chromium`
68
+ return {
69
+ /* --with-deps installs the shared libraries through apt; other distros only get the browser */
70
+ "linux-apt": `bunx playwright@${version} install --with-deps chromium`,
71
+ "linux-dnf": browserOnly,
72
+ "linux-pacman": browserOnly,
73
+ macos: browserOnly,
74
+ windows: browserOnly,
75
+ }
76
+ },
77
+ versions: "pinned",
78
+ },
79
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cloudflare: the cf CLI and the cloudflared tunnel client.
3
+ */
4
+
5
+ import { archive, binary, bunGlobal, localBinEnv, pick, unix, winBinary } from "./recipes.ts"
6
+ import type { BuiltinDep } from "./types.ts"
7
+
8
+ export const CLOUDFLARE = {
9
+ cf: {
10
+ /* installed by bun; its launcher is a node script */
11
+ dependsOn: ["bun", "node"],
12
+ description: "Cloudflare CLI: deploy, workers types, account operations (bun global, runs on node)",
13
+ os: ({ version }) => bunGlobal("cf", version),
14
+ versions: "pinned",
15
+ },
16
+ cloudflared: {
17
+ description: "Cloudflare Tunnel client, for remote bindings",
18
+ env: localBinEnv,
19
+ os: ({ arch, version }) => {
20
+ const base = `https://github.com/cloudflare/cloudflared/releases/download/${version}/cloudflared`
21
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
22
+ return {
23
+ ...unix(
24
+ binary(`${base}-linux-${a}`, "cloudflared"),
25
+ archive(`${base}-darwin-${a}.tgz`, "cloudflared", "cloudflared"),
26
+ ),
27
+ windows: winBinary(`${base}-windows-amd64.exe`, "cloudflared"),
28
+ }
29
+ },
30
+ versions: "pinned",
31
+ },
32
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Database tooling: Atlas migrations and the libSQL server.
3
+ */
4
+
5
+ import { archive, binary, localBinEnv, pick, unix, winBinary } from "./recipes.ts"
6
+ import type { BuiltinDep } from "./types.ts"
7
+
8
+ export const DATABASE = {
9
+ atlas: {
10
+ check: { command: "atlas version" },
11
+ description: "Atlas schema migrations",
12
+ env: localBinEnv,
13
+ os: ({ arch, version }) => {
14
+ const base = "https://release.ariga.io/atlas/atlas"
15
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
16
+ return {
17
+ ...unix(binary(`${base}-linux-${a}-v${version}`, "atlas"), binary(`${base}-darwin-${a}-v${version}`, "atlas")),
18
+ windows: winBinary(`${base}-windows-amd64-v${version}.exe`, "atlas"),
19
+ }
20
+ },
21
+ versions: "pinned",
22
+ },
23
+ sqld: {
24
+ description: "libSQL server, for local database tests (no Windows build)",
25
+ env: localBinEnv,
26
+ os: ({ arch, version }) => {
27
+ const base = `https://github.com/tursodatabase/libsql/releases/download/libsql-server-v${version}/libsql-server`
28
+ const cpu = pick(arch, { arm64: "aarch64", x64: "x86_64" })
29
+ const linux = `${cpu}-unknown-linux-gnu`
30
+ const mac = `${cpu}-apple-darwin`
31
+ return {
32
+ ...unix(
33
+ archive(`${base}-${linux}.tar.xz`, `libsql-server-${linux}/sqld`, "sqld"),
34
+ archive(`${base}-${mac}.tar.xz`, `libsql-server-${mac}/sqld`, "sqld"),
35
+ ),
36
+ windows: false,
37
+ }
38
+ },
39
+ versions: "pinned",
40
+ },
41
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The built-in catalog: every tool workerstack supports by name. A config gives `name: "<version>"`;
3
+ * the catalog supplies the install commands, check, env and prerequisites.
4
+ */
5
+
6
+ import { AGENTS } from "./agents.ts"
7
+ import { BOOTSTRAP } from "./bootstrap.ts"
8
+ import { BROWSERS } from "./browsers.ts"
9
+ import { CLOUDFLARE } from "./cloudflare.ts"
10
+ import { DATABASE } from "./database.ts"
11
+ import { LANGUAGES } from "./languages.ts"
12
+ import { RELEASE } from "./release.ts"
13
+ import { RUNTIMES } from "./runtimes.ts"
14
+ import type { BuiltinDep } from "./types.ts"
15
+
16
+ /** The catalog by group, for docs and per-group CI installs. */
17
+ export const BUILTIN_GROUPS = {
18
+ bootstrap: BOOTSTRAP,
19
+ runtimes: RUNTIMES,
20
+ languages: LANGUAGES,
21
+ release: RELEASE,
22
+ cloudflare: CLOUDFLARE,
23
+ database: DATABASE,
24
+ browsers: BROWSERS,
25
+ agents: AGENTS,
26
+ } as const
27
+
28
+ export const BUILTINS = {
29
+ ...BOOTSTRAP,
30
+ ...RUNTIMES,
31
+ ...LANGUAGES,
32
+ ...RELEASE,
33
+ ...CLOUDFLARE,
34
+ ...DATABASE,
35
+ ...BROWSERS,
36
+ ...AGENTS,
37
+ } satisfies Record<string, BuiltinDep>
38
+
39
+ export type BuiltinName = keyof typeof BUILTINS
40
+
41
+ export const BUILTIN_NAMES: ReadonlySet<string> = new Set(Object.keys(BUILTINS))
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Every language the generated clients target: Go, Rust, Python, C#, Java, with their formatters.
3
+ * (TypeScript runs on bun, in the runtimes group.)
4
+ */
5
+
6
+ import type { OsTarget } from "../config.ts"
7
+ import {
8
+ binary,
9
+ LOCAL_BIN,
10
+ LOCAL_SHARE,
11
+ localBinEnv,
12
+ pick,
13
+ ps,
14
+ tree,
15
+ unix,
16
+ WIN_PROGRAMS,
17
+ winBinary,
18
+ winPathDir,
19
+ winTree,
20
+ } from "./recipes.ts"
21
+ import type { Arch, BuiltinDep } from "./types.ts"
22
+
23
+ /** uv puts Python and tool shims in `~/.local/bin` (`%USERPROFILE%\.local\bin` on Windows). */
24
+ function uvBinEnv(os: OsTarget) {
25
+ return [{ path: os === "windows" ? "$HOME\\.local\\bin" : LOCAL_BIN }]
26
+ }
27
+
28
+ /**
29
+ * A uv command whose shims land in the bin dir `uvBinEnv` puts on PATH. On unix uv derives that dir from
30
+ * XDG_BIN_HOME / XDG_DATA_HOME, which can point anywhere, so the dir is named explicitly.
31
+ */
32
+ function uvCommand(variable: "UV_PYTHON_BIN_DIR" | "UV_TOOL_BIN_DIR", command: string) {
33
+ const pinned = `${variable}="${LOCAL_BIN}" ${command}`
34
+ return { ...unix(pinned, pinned), windows: command }
35
+ }
36
+
37
+ /** Adoptium download for a feature major (`21`, latest GA) or an exact release (`21.0.13+9`). */
38
+ function temurin(version: string, os: "linux" | "mac" | "windows", arch: Arch): string {
39
+ const api = "https://api.adoptium.net/v3/binary"
40
+ const a = pick(arch, { arm64: "aarch64", x64: "x64" })
41
+ const tail = `${os}/${a}/jdk/hotspot/normal/eclipse`
42
+ return /^\d+$/.test(version)
43
+ ? `${api}/latest/${version}/ga/${tail}`
44
+ : `${api}/version/jdk-${encodeURIComponent(version)}/${tail}`
45
+ }
46
+
47
+ const JAVA_DIR = `${LOCAL_SHARE}/java`
48
+ /** macOS JDKs keep their home under Contents/Home. */
49
+ const javaHome = (os: OsTarget) =>
50
+ os === "windows" ? `${WIN_PROGRAMS}\\java` : os === "macos" ? `${JAVA_DIR}/Contents/Home` : JAVA_DIR
51
+
52
+ const GJF_JAR = `${LOCAL_SHARE}/google-java-format/google-java-format.jar`
53
+
54
+ export const LANGUAGES = {
55
+ dotnet: {
56
+ description: ".NET SDK, for the generated C# SDK (dotnet format included)",
57
+ env: (os) => {
58
+ const dir = os === "windows" ? `${WIN_PROGRAMS}\\dotnet` : `${LOCAL_SHARE}/dotnet`
59
+ return [{ key: "DOTNET_ROOT", value: dir }, { path: dir }]
60
+ },
61
+ os: ({ version }) => {
62
+ const sh = `curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --version ${version} --install-dir "${LOCAL_SHARE}/dotnet" --no-path`
63
+ return {
64
+ ...unix(sh, sh),
65
+ /* Fedora images lack the ICU library .NET needs at startup */
66
+ "linux-dnf": `sudo dnf install -y libicu && ${sh}`,
67
+ windows: ps(
68
+ '$s = Join-Path $env:TEMP "dotnet-install.ps1"',
69
+ "Invoke-WebRequest -UseBasicParsing https://dot.net/v1/dotnet-install.ps1 -OutFile $s",
70
+ `& $s -Version ${version} -InstallDir "${WIN_PROGRAMS}\\dotnet" -NoPath`,
71
+ winPathDir(`${WIN_PROGRAMS}\\dotnet`),
72
+ ),
73
+ }
74
+ },
75
+ versions: "pinned",
76
+ },
77
+ go: {
78
+ check: { command: "go version", pattern: "go(\\d+\\.\\d+(?:\\.\\d+)?)" },
79
+ description: "Go toolchain (gofmt included), for the generated Go SDK and CLI",
80
+ env: (os) =>
81
+ os === "windows"
82
+ ? [{ path: `${WIN_PROGRAMS}\\go\\bin` }, { path: "$HOME\\go\\bin" }]
83
+ : [{ path: `${LOCAL_SHARE}/go/bin` }, { path: "$HOME/go/bin" }],
84
+ os: ({ arch, version }) => {
85
+ const base = `https://go.dev/dl/go${version}`
86
+ const a = pick(arch, { arm64: "arm64", x64: "amd64" })
87
+ const dir = `${LOCAL_SHARE}/go`
88
+ return {
89
+ ...unix(
90
+ tree(`${base}.linux-${a}.tar.gz`, dir, { nested: true }),
91
+ tree(`${base}.darwin-${a}.tar.gz`, dir, { nested: true }),
92
+ ),
93
+ windows: winTree(`${base}.windows-amd64.zip`, `${WIN_PROGRAMS}\\go`, { bin: "bin", nested: true }),
94
+ }
95
+ },
96
+ versions: "pinned",
97
+ },
98
+ "google-java-format": {
99
+ check: { command: "google-java-format --version 2>&1" },
100
+ dependsOn: ["java"],
101
+ description: "google-java-format, the generated Java SDK's formatter",
102
+ env: localBinEnv,
103
+ os: ({ arch, version }) => {
104
+ const base = `https://github.com/google/google-java-format/releases/download/v${version}`
105
+ /* no native macOS x64 build: run the all-deps jar on the java dep */
106
+ const jar = [
107
+ `mkdir -p "$(dirname "${GJF_JAR}")" "${LOCAL_BIN}"`,
108
+ `curl -fsSL "${base}/google-java-format-${version}-all-deps.jar" -o "${GJF_JAR}"`,
109
+ `printf '#!/bin/sh\\nexec java -jar "${GJF_JAR}" "$@"\\n' > "${LOCAL_BIN}/google-java-format"`,
110
+ `chmod +x "${LOCAL_BIN}/google-java-format"`,
111
+ ].join(" && ")
112
+ return {
113
+ ...unix(
114
+ binary(
115
+ `${base}/google-java-format_linux-${pick(arch, { arm64: "arm64", x64: "x86-64" })}`,
116
+ "google-java-format",
117
+ ),
118
+ arch === "arm64" ? binary(`${base}/google-java-format_darwin-arm64`, "google-java-format") : jar,
119
+ ),
120
+ windows: winBinary(`${base}/google-java-format_windows-x86-64.exe`, "google-java-format"),
121
+ }
122
+ },
123
+ versions: "pinned",
124
+ },
125
+ gradle: {
126
+ check: { command: "gradle --version", pattern: "Gradle (\\d+\\.\\d+(?:\\.\\d+)?)" },
127
+ dependsOn: ["java"],
128
+ description: "Gradle, the generated Java SDK's build",
129
+ env: (os) => [{ path: os === "windows" ? `${WIN_PROGRAMS}\\gradle\\bin` : `${LOCAL_SHARE}/gradle/bin` }],
130
+ os: ({ version }) => {
131
+ const zip = `https://services.gradle.org/distributions/gradle-${version}-bin.zip`
132
+ const sh = tree(zip, `${LOCAL_SHARE}/gradle`, { nested: true })
133
+ return {
134
+ ...unix(sh, sh),
135
+ windows: winTree(zip, `${WIN_PROGRAMS}\\gradle`, { bin: "bin", nested: true }),
136
+ }
137
+ },
138
+ versions: "pinned",
139
+ },
140
+ java: {
141
+ /* `java -version` prints to stderr; the build line carries the full release (21.0.13+9) */
142
+ check: { command: "java -version 2>&1", pattern: "build (\\d+[\\d.]*\\+\\d+)" },
143
+ description: "Eclipse Temurin JDK: a feature major (21) or an exact release (21.0.13+9)",
144
+ env: (os) => [
145
+ { key: "JAVA_HOME", value: javaHome(os) },
146
+ { path: os === "windows" ? `${javaHome(os)}\\bin` : `${javaHome(os)}/bin` },
147
+ ],
148
+ os: ({ arch, version }) => ({
149
+ ...unix(
150
+ tree(temurin(version, "linux", arch), JAVA_DIR, { nested: true }),
151
+ tree(temurin(version, "mac", arch), JAVA_DIR, { nested: true }),
152
+ ),
153
+ windows: winTree(temurin(version, "windows", arch), `${WIN_PROGRAMS}\\java`, { bin: "bin", nested: true }),
154
+ }),
155
+ versions: "pinned",
156
+ },
157
+ mypy: {
158
+ dependsOn: ["uv"],
159
+ description: "mypy, the generated Python SDK's type check (uv tool)",
160
+ env: uvBinEnv,
161
+ os: ({ version }) => uvCommand("UV_TOOL_BIN_DIR", `uv tool install --force mypy==${version}`),
162
+ versions: "pinned",
163
+ },
164
+ python: {
165
+ check: { command: "python --version" },
166
+ dependsOn: ["uv"],
167
+ description: "Python, managed by uv (python and python3 shims)",
168
+ env: uvBinEnv,
169
+ os: ({ version }) => uvCommand("UV_PYTHON_BIN_DIR", `uv python install ${version} --default`),
170
+ versions: "pinned",
171
+ },
172
+ ruff: {
173
+ dependsOn: ["uv"],
174
+ description: "ruff, the generated Python SDK's formatter and linter (uv tool)",
175
+ env: uvBinEnv,
176
+ os: ({ version }) => uvCommand("UV_TOOL_BIN_DIR", `uv tool install --force ruff==${version}`),
177
+ versions: "pinned",
178
+ },
179
+ rust: {
180
+ check: { command: "rustc --version" },
181
+ description: "Rust via rustup (rustfmt and clippy), for the generated Rust SDK",
182
+ env: (os) => [{ path: os === "windows" ? "$HOME\\.cargo\\bin" : "$HOME/.cargo/bin" }],
183
+ os: ({ arch, version }) => {
184
+ const flags = `-y --default-toolchain ${version} --profile minimal -c rustfmt -c clippy`
185
+ return {
186
+ ...unix(
187
+ `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- ${flags} --no-modify-path`,
188
+ `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- ${flags} --no-modify-path`,
189
+ ),
190
+ /* rustup-init adds cargo\bin to the user PATH itself on Windows */
191
+ windows: ps(
192
+ '$r = Join-Path $env:TEMP "rustup-init.exe"',
193
+ `Invoke-WebRequest -UseBasicParsing "https://static.rust-lang.org/rustup/dist/${pick(arch, { arm64: "aarch64", x64: "x86_64" })}-pc-windows-msvc/rustup-init.exe" -OutFile $r`,
194
+ `& $r ${flags}`,
195
+ "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
196
+ ),
197
+ }
198
+ },
199
+ versions: "pinned",
200
+ },
201
+ uv: {
202
+ description: "uv, the Python toolchain manager",
203
+ env: localBinEnv,
204
+ os: ({ version }) => ({
205
+ ...unix(
206
+ `curl -LsSf https://astral.sh/uv/${version}/install.sh | env UV_INSTALL_DIR="${LOCAL_BIN}" UV_NO_MODIFY_PATH=1 sh`,
207
+ `curl -LsSf https://astral.sh/uv/${version}/install.sh | env UV_INSTALL_DIR="${LOCAL_BIN}" UV_NO_MODIFY_PATH=1 sh`,
208
+ ),
209
+ windows: ps(
210
+ winPathDir("$env:LOCALAPPDATA\\bin"),
211
+ '$env:UV_INSTALL_DIR = "$env:LOCALAPPDATA\\bin"',
212
+ '$env:UV_NO_MODIFY_PATH = "1"',
213
+ `irm https://astral.sh/uv/${version}/install.ps1 | iex`,
214
+ ),
215
+ }),
216
+ versions: "pinned",
217
+ },
218
+ } satisfies Record<string, BuiltinDep>
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Building blocks for built-in install commands. Unix installs land in `$HOME/.local/bin` (or a tool dir
3
+ * under `$HOME/.local/share`) without sudo; Windows installs land in `%LOCALAPPDATA%\bin` (or a tool dir
4
+ * under `%LOCALAPPDATA%\Programs`), which the command adds to the user PATH.
5
+ */
6
+
7
+ import type { EnvVar, OsCommands, OsTarget } from "../config.ts"
8
+ import type { Arch } from "./types.ts"
9
+
10
+ export const LOCAL_BIN = "$HOME/.local/bin"
11
+ export const LOCAL_SHARE = "$HOME/.local/share"
12
+ export const WIN_BIN = "$env:LOCALAPPDATA\\bin"
13
+ export const WIN_PROGRAMS = "$env:LOCALAPPDATA\\Programs"
14
+
15
+ /** The same command on every Linux distro, and one for macOS. */
16
+ export function unix(linux: string, macos: string): OsCommands {
17
+ return { "linux-apt": linux, "linux-dnf": linux, "linux-pacman": linux, macos }
18
+ }
19
+
20
+ /**
21
+ * A global JS CLI through bun, the only package manager. On unix bun derives its global bin dir from
22
+ * XDG_CACHE_HOME when set, so the dir bun's env block puts on PATH is named explicitly.
23
+ */
24
+ export function bunGlobal(pkg: string, version: string): OsCommands {
25
+ const command = `bun i -g ${pkg}@${version}`
26
+ const pinned = `BUN_INSTALL_BIN="$HOME/.bun/bin" ${command}`
27
+ return { ...unix(pinned, pinned), windows: command }
28
+ }
29
+
30
+ /** The value for this CPU. */
31
+ export function pick<T>(arch: Arch, values: { arm64: T; x64: T }): T {
32
+ return values[arch]
33
+ }
34
+
35
+ /** Download one executable into `$HOME/.local/bin/<name>`. */
36
+ export function binary(url: string, name: string): string {
37
+ return `mkdir -p "${LOCAL_BIN}" && curl -fsSL "${url}" -o "${LOCAL_BIN}/${name}" && chmod +x "${LOCAL_BIN}/${name}"`
38
+ }
39
+
40
+ /** Unpack `$tmp/archive` into `$tmp/x`: unzip for .zip (GNU tar cannot), tar otherwise. */
41
+ function extract(url: string): string {
42
+ if (url.endsWith(".zip")) return 'unzip -q "$tmp/archive" -d "$tmp/x"'
43
+ const flag = /\.(tar\.gz|tgz)$/.test(url) ? "z" : url.endsWith(".tar.xz") ? "J" : ""
44
+ return `tar -x${flag}f "$tmp/archive" -C "$tmp/x"`
45
+ }
46
+
47
+ function download(url: string): string[] {
48
+ return ['tmp="$(mktemp -d)"', `curl -fsSL "${url}" -o "$tmp/archive"`, 'mkdir "$tmp/x"', extract(url)]
49
+ }
50
+
51
+ const CLEANUP = 'rm -rf "$tmp"'
52
+
53
+ /** Download an archive and install `member` (a path inside it) as `$HOME/.local/bin/<name>`. */
54
+ export function archive(url: string, member: string, name: string): string {
55
+ return [
56
+ ...download(url),
57
+ `mkdir -p "${LOCAL_BIN}"`,
58
+ `install -m 755 "$tmp/x/${member}" "${LOCAL_BIN}/${name}"`,
59
+ CLEANUP,
60
+ ].join(" && ")
61
+ }
62
+
63
+ /**
64
+ * Unpack a whole archive into `dir` (replacing it), for tools that need their files next to the binary.
65
+ * `nested`: the archive holds one top folder (`go/`, `node-v24.21.0-linux-x64/`), which becomes `dir`.
66
+ * Each of `bins` (a path inside `dir`) is linked into `$HOME/.local/bin`.
67
+ */
68
+ export function tree(url: string, dir: string, opts: { bins?: readonly string[]; nested?: boolean } = {}): string {
69
+ const links = (opts.bins ?? []).map((bin) => `ln -sf "${dir}/${bin}" "${LOCAL_BIN}/${bin.split("/").pop()}"`)
70
+ return [
71
+ ...download(url),
72
+ `rm -rf "${dir}"`,
73
+ `mkdir -p "$(dirname "${dir}")" "${LOCAL_BIN}"`,
74
+ opts.nested ? `mv "$tmp"/x/* "${dir}"` : `mv "$tmp/x" "${dir}"`,
75
+ ...links,
76
+ CLEANUP,
77
+ ].join(" && ")
78
+ }
79
+
80
+ /** Stop on the first error and skip the progress bar (it slows Invoke-WebRequest badly). */
81
+ const PS_PRELUDE = "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'"
82
+
83
+ /** Make `dir` exist and be on the user PATH. */
84
+ export function winPathDir(dir: string): string {
85
+ return [
86
+ `$d = "${dir}"`,
87
+ "New-Item -ItemType Directory -Force $d | Out-Null",
88
+ '$u = [Environment]::GetEnvironmentVariable("Path", "User")',
89
+ 'if (-not (($u -split ";") -contains $d)) { [Environment]::SetEnvironmentVariable("Path", "$d;$u", "User") }',
90
+ ].join("; ")
91
+ }
92
+
93
+ /** Download one .exe into `%LOCALAPPDATA%\bin\<name>.exe`. */
94
+ export function winBinary(url: string, name: string): string {
95
+ return [
96
+ PS_PRELUDE,
97
+ winPathDir(WIN_BIN),
98
+ `Invoke-WebRequest -UseBasicParsing "${url}" -OutFile "${WIN_BIN}\\${name}.exe"`,
99
+ ].join("; ")
100
+ }
101
+
102
+ const WIN_TEMP = ["$t = Join-Path $env:TEMP ([guid]::NewGuid())", "New-Item -ItemType Directory $t | Out-Null"]
103
+
104
+ /** Download a zip and copy `member` (a path inside it) to `%LOCALAPPDATA%\bin\<name>.exe`. */
105
+ export function winArchive(url: string, member: string, name: string): string {
106
+ return [
107
+ PS_PRELUDE,
108
+ winPathDir(WIN_BIN),
109
+ ...WIN_TEMP,
110
+ `Invoke-WebRequest -UseBasicParsing "${url}" -OutFile "$t\\archive.zip"`,
111
+ 'Expand-Archive "$t\\archive.zip" "$t\\x"',
112
+ `Copy-Item "$t\\x\\${member}" "${WIN_BIN}\\${name}.exe" -Force`,
113
+ "Remove-Item -Recurse -Force $t",
114
+ ].join("; ")
115
+ }
116
+
117
+ /**
118
+ * Unpack a whole zip into `dir` (replacing it) and put `dir\<bin>` on the user PATH.
119
+ * `nested`: the zip holds one top folder, which becomes `dir`.
120
+ */
121
+ export function winTree(url: string, dir: string, opts: { bin?: string; nested?: boolean } = {}): string {
122
+ return [
123
+ PS_PRELUDE,
124
+ ...WIN_TEMP,
125
+ `Invoke-WebRequest -UseBasicParsing "${url}" -OutFile "$t\\archive.zip"`,
126
+ 'Expand-Archive "$t\\archive.zip" "$t\\x"',
127
+ `if (Test-Path "${dir}") { Remove-Item -Recurse -Force "${dir}" }`,
128
+ `New-Item -ItemType Directory -Force (Split-Path "${dir}") | Out-Null`,
129
+ opts.nested
130
+ ? `Move-Item (Get-ChildItem "$t\\x" -Directory | Select-Object -First 1).FullName "${dir}"`
131
+ : `Move-Item "$t\\x" "${dir}"`,
132
+ "Remove-Item -Recurse -Force $t",
133
+ winPathDir(opts.bin ? `${dir}\\${opts.bin}` : dir),
134
+ ].join("; ")
135
+ }
136
+
137
+ /** A Windows command run in PowerShell with the stop-on-error prelude. */
138
+ export function ps(...steps: string[]): string {
139
+ return [PS_PRELUDE, ...steps].join("; ")
140
+ }
141
+
142
+ /** PATH entries for a tool in the per-user bin dirs: `$HOME/.local/bin` on unix, `%LOCALAPPDATA%\bin` on Windows. */
143
+ export function localBinEnv(os: OsTarget): EnvVar[] {
144
+ return [{ path: os === "windows" ? WIN_BIN : LOCAL_BIN }]
145
+ }