From 7c76350f96464e6fd5c1691f2d8d574b76653601 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 27 Apr 2026 10:11:19 +0100 Subject: [PATCH 1/5] feat(npm): distribute the CLI via npm Adds the build and publish tooling for distributing the Upsun CLI as an npm package, so users can run `npm install -g upsun` or `npx upsun`. Implements the optionalDependencies pattern used by esbuild, swc, biome, turbo, and similar tools. A thin wrapper package (`upsun`) declares four platform-specific packages as optionalDependencies; npm uses each package's `os` and `cpu` fields to install only the matching one. The wrapper's bin script resolves that package at runtime and execs the embedded binary, forwarding argv, stdio, exit code, and signals. No postinstall script and no runtime download. Packages produced per release: upsun wrapper @upsun/cli-linux-x64 @upsun/cli-linux-arm64 @upsun/cli-darwin universal binary, x64 and arm64 @upsun/cli-win32-x64 macOS uses the universal binary that GoReleaser already builds, so a single darwin package covers both Apple Silicon and Intel. publish.sh classifies each tarball by reading its package.json once (wrapper = "upsun", everything else is a platform package), publishes the platform packages, waits for them to become queryable on the public registry, then publishes the wrapper. The wait matters: npm publish returns success before the package is visible to npm view, and any user running npx in that window gets a broken install in their npx cache that does not self-heal. Layout: - npm/wrapper: shim package with bin/upsun.js - npm/platform-template: template for the per-platform packages - npm/scripts/build.sh: assembles tarballs from GoReleaser archives - npm/scripts/publish.sh: classifies, publishes, waits, then wrapper - Makefile targets: npm-pack, npm-publish, npm-clean Verified end-to-end against the live registry: build, publish, install via `npx upsun`, exec, argv passthrough, and exit-code propagation all work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + Makefile | 15 +++ npm/README.md | 78 ++++++++++++ npm/platform-template/README.md.tmpl | 7 ++ npm/platform-template/package.json.tmpl | 17 +++ npm/scripts/build.sh | 151 ++++++++++++++++++++++++ npm/scripts/publish.sh | 95 +++++++++++++++ npm/wrapper/README.md | 21 ++++ npm/wrapper/bin/upsun.js | 53 +++++++++ npm/wrapper/package.json.tmpl | 27 +++++ 10 files changed, 465 insertions(+) create mode 100644 npm/README.md create mode 100644 npm/platform-template/README.md.tmpl create mode 100644 npm/platform-template/package.json.tmpl create mode 100755 npm/scripts/build.sh create mode 100755 npm/scripts/publish.sh create mode 100644 npm/wrapper/README.md create mode 100644 npm/wrapper/bin/upsun.js create mode 100644 npm/wrapper/package.json.tmpl diff --git a/.gitignore b/.gitignore index ce600154a..1ec02cca2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ internal/legacy/archives/* dist/ +npm/dist/ php-* completion diff --git a/Makefile b/Makefile index 63a0f88f8..d913e5455 100644 --- a/Makefile +++ b/Makefile @@ -155,3 +155,18 @@ vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy .PHONY: goreleaser-check goreleaser-check: goreleaser ## Check the goreleaser configs PHP_VERSION=$(PHP_VERSION) goreleaser check --config=.goreleaser.yaml + +# ----- npm distribution ----- +# See npm/README.md. + +.PHONY: npm-pack +npm-pack: ## Build npm tarballs from existing GoReleaser archives in dist/ + bash npm/scripts/build.sh + +.PHONY: npm-publish +npm-publish: ## Publish npm tarballs (requires npm auth). NPM_TAG=latest|next, DRY_RUN=1 to dry-run + bash npm/scripts/publish.sh + +.PHONY: npm-clean +npm-clean: ## Remove npm/dist working directory + rm -rf npm/dist diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 000000000..6dfaba3a5 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,78 @@ +# npm distribution + +Tooling to ship the Upsun CLI as an npm package, so users can run +`npm install -g upsun` or `npx upsun`. Implements the +`optionalDependencies` pattern used by esbuild, swc, biome, turbo, and +others: a small wrapper package selects the right platform-specific +package at install time, so each user only downloads the binary that +matches their OS and CPU. No postinstall script, no runtime download. + +## Packages + +| Package | Contents | +| ------------------------ | --------------------------------------- | +| `upsun` | wrapper, with the four platforms below as `optionalDependencies` | +| `@upsun/cli-linux-x64` | Linux amd64 binary | +| `@upsun/cli-linux-arm64` | Linux arm64 binary | +| `@upsun/cli-darwin` | macOS universal binary (x64 + arm64) | +| `@upsun/cli-win32-x64` | Windows amd64 binary | + +## Layout + +``` +npm/ +├── wrapper/ wrapper package source +│ ├── bin/upsun.js shim that resolves the platform package and execs the binary +│ ├── package.json.tmpl stamped with version at build time +│ └── README.md shipped to the registry as the wrapper README +├── platform-template/ common template for all platform-specific packages +│ ├── package.json.tmpl stamped per-target with name, version, os, cpu +│ └── README.md.tmpl +├── scripts/ +│ ├── build.sh assembles tarballs from GoReleaser archives +│ └── publish.sh publishes tarballs in lockstep +└── dist/ build output (npm pack tarballs); gitignored +``` + +## Build + +```sh +make snapshot-no-nfpm # or any goreleaser invocation that writes upsun_*.tar.gz/zip into dist/ +make npm-pack # reads dist/, writes npm/dist/*.tgz +``` + +The build script resolves the version from the GoReleaser archive +filenames. Override with `VERSION=...` if you need to. + +## Publish + +```sh +make npm-publish # publish all five packages in lockstep +DRY_RUN=1 make npm-publish # validate without publishing +NPM_TAG=next make npm-publish # for prereleases +``` + +The script publishes platform packages first, then the wrapper, so the +registry is never in a state where the wrapper points at platform +packages that don't yet exist. + +Auth is via the standard npm mechanism: `~/.npmrc` with a token, or the +`actions/setup-node` action in CI populating one for you from +`NODE_AUTH_TOKEN`. The `--access public` flag is set so first-time +publishes of scoped packages do not get marked private. + +## Versioning + +Every npm release uses the same version as the corresponding GitHub +release tag. Platform packages and the wrapper are always published in +lockstep at the same version; the wrapper's `optionalDependencies` pin +exact versions, so a mismatched set will not resolve. + +## Known limitations + +- `npm install --no-optional` (or `--omit=optional`) skips the platform + package, and the wrapper exits with a clear error pointing at the flag. +- `darwin-arm64` and `darwin-x64` share a single universal binary + package. This roughly doubles the macOS install size relative to + per-arch packages, but matches the artifact GoReleaser produces and + keeps the package set smaller. diff --git a/npm/platform-template/README.md.tmpl b/npm/platform-template/README.md.tmpl new file mode 100644 index 000000000..10d45d6a4 --- /dev/null +++ b/npm/platform-template/README.md.tmpl @@ -0,0 +1,7 @@ +# __PKG_NAME__ + +Platform-specific binary for the [Upsun CLI](https://github.com/upsun/cli). + +This package is installed automatically by the `upsun` wrapper as an +`optionalDependency` matching your operating system and CPU. You do not +need to install it directly. diff --git a/npm/platform-template/package.json.tmpl b/npm/platform-template/package.json.tmpl new file mode 100644 index 000000000..4e96717bc --- /dev/null +++ b/npm/platform-template/package.json.tmpl @@ -0,0 +1,17 @@ +{ + "name": "__PKG_NAME__", + "version": "__VERSION__", + "description": "__DESCRIPTION__", + "homepage": "https://docs.upsun.com/anchors/cli/", + "repository": { + "type": "git", + "url": "git+https://github.com/upsun/cli.git" + }, + "license": "MIT", + "files": [ + "bin", + "README.md" + ], + "os": __OS__, + "cpu": __CPU__ +} diff --git a/npm/scripts/build.sh b/npm/scripts/build.sh new file mode 100755 index 000000000..29ee73434 --- /dev/null +++ b/npm/scripts/build.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Assembles npm packages from GoReleaser archives. +# +# Inputs (env vars, all optional): +# DIST_DIR Directory containing GoReleaser archives. Default: /dist +# VERSION Package version. Default: derived from the first matching archive name. +# OUT_DIR Where to write per-package working dirs and tarballs. Default: npm/dist +# +# Produces: +# upsun (wrapper, with the four platforms below as optionalDependencies) +# @upsun/cli-linux-x64 +# @upsun/cli-linux-arm64 +# @upsun/cli-darwin (universal binary; covers x64 and arm64) +# @upsun/cli-win32-x64 + +set -euo pipefail + +NPM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${NPM_DIR}/.." && pwd)" + +DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}" +OUT_DIR="${OUT_DIR:-${NPM_DIR}/dist}" + +if [ ! -d "${DIST_DIR}" ]; then + echo "build.sh: DIST_DIR not found: ${DIST_DIR}" >&2 + echo "Run 'goreleaser release --snapshot --clean' first, or point DIST_DIR at the archives." >&2 + exit 1 +fi + +# Maps suffix -> archive glob, binary filename, os JSON, cpu JSON. +# The darwin entry has a permissive cpu list because macOS ships a +# single universal binary that runs on both Apple Silicon and Intel. +declare -A ARCHIVE_GLOB=( + [linux-x64]="upsun_*_linux_amd64.tar.gz" + [linux-arm64]="upsun_*_linux_arm64.tar.gz" + [darwin]="upsun_*_darwin_all.tar.gz" + [win32-x64]="upsun_*_windows_amd64.zip" +) +declare -A BIN_NAME=( + [linux-x64]="upsun" + [linux-arm64]="upsun" + [darwin]="upsun" + [win32-x64]="upsun.exe" +) +declare -A OS_JSON=( + [linux-x64]='["linux"]' + [linux-arm64]='["linux"]' + [darwin]='["darwin"]' + [win32-x64]='["win32"]' +) +declare -A CPU_JSON=( + [linux-x64]='["x64"]' + [linux-arm64]='["arm64"]' + [darwin]='["x64","arm64"]' + [win32-x64]='["x64"]' +) +declare -A DESCRIPTION=( + [linux-x64]="Upsun CLI binary for Linux x64" + [linux-arm64]="Upsun CLI binary for Linux arm64" + [darwin]="Upsun CLI binary for macOS (universal)" + [win32-x64]="Upsun CLI binary for Windows x64" +) + +PLATFORMS=(linux-x64 linux-arm64 darwin win32-x64) + +if [ -z "${VERSION:-}" ]; then + shopt -s nullglob + matches=("${DIST_DIR}"/upsun_*_linux_amd64.tar.gz) + shopt -u nullglob + if [ ${#matches[@]} -eq 0 ]; then + echo "build.sh: no upsun_*_linux_amd64.tar.gz in ${DIST_DIR}; set VERSION explicitly" >&2 + exit 1 + fi + base="$(basename "${matches[0]}")" + # upsun_X.Y.Z_linux_amd64.tar.gz -> X.Y.Z + VERSION="${base#upsun_}" + VERSION="${VERSION%_linux_amd64.tar.gz}" +fi + +echo "build.sh: VERSION=${VERSION}" + +rm -rf "${OUT_DIR}" +mkdir -p "${OUT_DIR}" + +build_platform_pkg() { + local suffix="$1" + local glob="${ARCHIVE_GLOB[$suffix]}" + local bin="${BIN_NAME[$suffix]}" + local name="@upsun/cli-${suffix}" + + shopt -s nullglob + # shellcheck disable=SC2206 # intentional glob expansion + local archives=("${DIST_DIR}"/${glob}) + shopt -u nullglob + if [ ${#archives[@]} -eq 0 ]; then + echo "build.sh: no archive matching ${glob} in ${DIST_DIR}" >&2 + exit 1 + fi + local archive="${archives[0]}" + + local pkg_dir="${OUT_DIR}/${suffix}" + mkdir -p "${pkg_dir}/bin" + + case "${archive}" in + *.tar.gz) tar -xzf "${archive}" -C "${pkg_dir}/bin" "${bin}" ;; + *.zip) unzip -p "${archive}" "${bin}" > "${pkg_dir}/bin/${bin}" ;; + *) echo "build.sh: unsupported archive: ${archive}" >&2; exit 1 ;; + esac + chmod +x "${pkg_dir}/bin/${bin}" || true + + sed \ + -e "s|__PKG_NAME__|${name}|g" \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__DESCRIPTION__|${DESCRIPTION[$suffix]}|g" \ + -e "s|__OS__|${OS_JSON[$suffix]}|g" \ + -e "s|__CPU__|${CPU_JSON[$suffix]}|g" \ + "${NPM_DIR}/platform-template/package.json.tmpl" > "${pkg_dir}/package.json" + + sed -e "s|__PKG_NAME__|${name}|g" \ + "${NPM_DIR}/platform-template/README.md.tmpl" > "${pkg_dir}/README.md" + + (cd "${pkg_dir}" && npm pack --pack-destination "${OUT_DIR}" >/dev/null) + echo " packed ${name}@${VERSION}" +} + +build_wrapper_pkg() { + local pkg_dir="${OUT_DIR}/wrapper" + mkdir -p "${pkg_dir}/bin" + + sed -e "s|__VERSION__|${VERSION}|g" \ + "${NPM_DIR}/wrapper/package.json.tmpl" > "${pkg_dir}/package.json" + + cp "${NPM_DIR}/wrapper/bin/upsun.js" "${pkg_dir}/bin/upsun.js" + chmod +x "${pkg_dir}/bin/upsun.js" + + cp "${NPM_DIR}/wrapper/README.md" "${pkg_dir}/README.md" + + (cd "${pkg_dir}" && npm pack --pack-destination "${OUT_DIR}" >/dev/null) + echo " packed upsun@${VERSION}" +} + +echo "build.sh: building platform packages" +for suffix in "${PLATFORMS[@]}"; do + build_platform_pkg "$suffix" +done + +echo "build.sh: building wrapper package" +build_wrapper_pkg + +echo "build.sh: done. Tarballs in ${OUT_DIR}:" +ls -1 "${OUT_DIR}"/*.tgz diff --git a/npm/scripts/publish.sh b/npm/scripts/publish.sh new file mode 100755 index 000000000..9f387eb7a --- /dev/null +++ b/npm/scripts/publish.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Publishes the npm tarballs produced by build.sh. +# +# Inputs (env vars): +# OUT_DIR Where build.sh wrote tarballs. Default: npm/dist +# NPM_TAG dist-tag, e.g. "latest" or "next". Default: "latest" +# DRY_RUN 1 to run npm publish --dry-run. Default: 0 +# +# Auth: requires ~/.npmrc to have a working //registry.npmjs.org/:_authToken, +# or NODE_AUTH_TOKEN set with a registry-url-configured ~/.npmrc (the +# setup-node action handles this in CI). +# +# Order: platform packages first, then wait for them to become visible +# in the public registry, then publish the wrapper. The wait matters: +# npm publish returns success before the new package is queryable via +# `npm view`. If a user runs `npx upsun` in that window, npm fails to +# resolve the wrapper's optionalDependencies, treats them as failed +# (which is silent for optional deps), and caches a broken install in +# ~/.npm/_npx that will not self-heal on retry. + +set -euo pipefail + +NPM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${OUT_DIR:-${NPM_DIR}/dist}" +NPM_TAG="${NPM_TAG:-latest}" +DRY_RUN="${DRY_RUN:-0}" + +if [ ! -d "${OUT_DIR}" ]; then + echo "publish.sh: OUT_DIR not found: ${OUT_DIR}. Run build.sh first." >&2 + exit 1 +fi + +shopt -s nullglob +all_tarballs=("${OUT_DIR}"/*.tgz) +shopt -u nullglob + +if [ ${#all_tarballs[@]} -eq 0 ]; then + echo "publish.sh: no tarballs in ${OUT_DIR}" >&2 + exit 1 +fi + +# Classify each tarball by reading its package.json once: the wrapper is +# the one named "upsun"; everything else is a platform package. Cache +# name and version so the propagation wait does not re-open the tarball. +declare -A NAME_OF VERSION_OF +platform_tarballs=() +wrapper_tarballs=() +for t in "${all_tarballs[@]}"; do + pkg_json=$(tar -xzOf "$t" package/package.json) + NAME_OF["$t"]=$(awk -F'"' '/"name":/ { print $4; exit }' <<<"$pkg_json") + VERSION_OF["$t"]=$(awk -F'"' '/"version":/ { print $4; exit }' <<<"$pkg_json") + if [ "${NAME_OF[$t]}" = "upsun" ]; then + wrapper_tarballs+=("$t") + else + platform_tarballs+=("$t") + fi +done + +publish_one() { + local tarball="$1" + local args=(publish "$tarball" --access public --tag "${NPM_TAG}") + if [ "${DRY_RUN}" = "1" ]; then args+=(--dry-run); fi + echo " npm ${args[*]}" + npm "${args[@]}" +} + +wait_visible() { + local pkg="$1" + local version="$2" + local deadline=$(($(date +%s) + 300)) + while ! npm view "${pkg}@${version}" version >/dev/null 2>&1; do + if [ "$(date +%s)" -gt "$deadline" ]; then + echo "publish.sh: timed out waiting for ${pkg}@${version} to propagate" >&2 + exit 1 + fi + echo " waiting for ${pkg}@${version}..." + sleep 5 + done + echo " ${pkg}@${version} visible" +} + +echo "publish.sh: publishing platform packages" +for t in "${platform_tarballs[@]}"; do publish_one "$t"; done + +if [ "${DRY_RUN}" != "1" ]; then + echo "publish.sh: waiting for platform packages to propagate" + for t in "${platform_tarballs[@]}"; do + wait_visible "${NAME_OF[$t]}" "${VERSION_OF[$t]}" + done +fi + +echo "publish.sh: publishing wrapper" +for t in "${wrapper_tarballs[@]}"; do publish_one "$t"; done + +echo "publish.sh: done" diff --git a/npm/wrapper/README.md b/npm/wrapper/README.md new file mode 100644 index 000000000..5a79c8881 --- /dev/null +++ b/npm/wrapper/README.md @@ -0,0 +1,21 @@ +# Upsun CLI + +The Upsun command-line interface, packaged for npm. + +## Install + +```sh +npm install -g upsun +# or run on demand: +npx upsun --version +``` + +This package is a thin Node.js wrapper that resolves and executes a +platform-specific binary installed via `optionalDependencies`. On install, +npm picks the matching binary for your OS and architecture; nothing is +downloaded at runtime. + +## Source + +Code, issues, and full documentation live at +[github.com/upsun/cli](https://github.com/upsun/cli). diff --git a/npm/wrapper/bin/upsun.js b/npm/wrapper/bin/upsun.js new file mode 100644 index 000000000..d6bf6aa25 --- /dev/null +++ b/npm/wrapper/bin/upsun.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Resolves the platform-specific package installed via optionalDependencies, +// then execs the embedded binary, forwarding argv, stdio, and exit code. + +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +// macOS ships a single universal binary, so both Apple Silicon and +// Intel resolve to the same "darwin" package. +const TARGETS = { + "darwin:x64": { suffix: "darwin", binary: "upsun" }, + "darwin:arm64": { suffix: "darwin", binary: "upsun" }, + "linux:x64": { suffix: "linux-x64", binary: "upsun" }, + "linux:arm64": { suffix: "linux-arm64", binary: "upsun" }, + "win32:x64": { suffix: "win32-x64", binary: "upsun.exe" }, +}; + +const target = TARGETS[`${process.platform}:${process.arch}`]; +if (!target) { + console.error( + `upsun: no prebuilt binary for ${process.platform}-${process.arch}.`, + ); + process.exit(1); +} + +const pkgName = `@upsun/cli-${target.suffix}`; + +let binary; +try { + // require.resolve handles flat, nested, and pnpm-style installs. + const pkgJsonPath = require.resolve(`${pkgName}/package.json`); + binary = path.join(path.dirname(pkgJsonPath), "bin", target.binary); +} catch (err) { + console.error( + `upsun: platform package "${pkgName}" is not installed.\n` + + `If you installed with --no-optional or --ignore-optional, reinstall without that flag.\n` + + `Original error: ${err.message}`, + ); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); + +if (result.error) { + console.error(`upsun: failed to exec ${binary}: ${result.error.message}`); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); +} + +process.exit(result.status ?? 1); diff --git a/npm/wrapper/package.json.tmpl b/npm/wrapper/package.json.tmpl new file mode 100644 index 000000000..1301307b5 --- /dev/null +++ b/npm/wrapper/package.json.tmpl @@ -0,0 +1,27 @@ +{ + "name": "upsun", + "version": "__VERSION__", + "description": "Upsun CLI", + "homepage": "https://docs.upsun.com/anchors/cli/", + "repository": { + "type": "git", + "url": "git+https://github.com/upsun/cli.git" + }, + "license": "MIT", + "bin": { + "upsun": "bin/upsun.js" + }, + "files": [ + "bin/upsun.js", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@upsun/cli-linux-x64": "__VERSION__", + "@upsun/cli-linux-arm64": "__VERSION__", + "@upsun/cli-darwin": "__VERSION__", + "@upsun/cli-win32-x64": "__VERSION__" + } +} From 568d34ec961c5a293a1ef7b5ac318a912eda3331 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 27 Apr 2026 10:26:10 +0100 Subject: [PATCH 2/5] fix(npm): make scripts compatible with Bash 3.2 and stricter chmod Address review comments on PR #46: - Replace declare -A associative arrays in build.sh and publish.sh with case-statement helpers and parallel arrays, so the scripts run on macOS's default /bin/bash (3.2). - Stop swallowing chmod failures on Unix targets in build.sh; only the Windows binary, where the exec bit is meaningless, keeps || true. Co-Authored-By: Claude Opus 4.7 (1M context) --- npm/scripts/build.sh | 99 ++++++++++++++++++++++++++---------------- npm/scripts/publish.sh | 21 +++++---- 2 files changed, 74 insertions(+), 46 deletions(-) diff --git a/npm/scripts/build.sh b/npm/scripts/build.sh index 29ee73434..d1171b4ad 100755 --- a/npm/scripts/build.sh +++ b/npm/scripts/build.sh @@ -27,42 +27,59 @@ if [ ! -d "${DIST_DIR}" ]; then exit 1 fi -# Maps suffix -> archive glob, binary filename, os JSON, cpu JSON. +# Per-suffix metadata. Implemented as case statements rather than +# associative arrays so the script works on macOS's default Bash 3.2. # The darwin entry has a permissive cpu list because macOS ships a # single universal binary that runs on both Apple Silicon and Intel. -declare -A ARCHIVE_GLOB=( - [linux-x64]="upsun_*_linux_amd64.tar.gz" - [linux-arm64]="upsun_*_linux_arm64.tar.gz" - [darwin]="upsun_*_darwin_all.tar.gz" - [win32-x64]="upsun_*_windows_amd64.zip" -) -declare -A BIN_NAME=( - [linux-x64]="upsun" - [linux-arm64]="upsun" - [darwin]="upsun" - [win32-x64]="upsun.exe" -) -declare -A OS_JSON=( - [linux-x64]='["linux"]' - [linux-arm64]='["linux"]' - [darwin]='["darwin"]' - [win32-x64]='["win32"]' -) -declare -A CPU_JSON=( - [linux-x64]='["x64"]' - [linux-arm64]='["arm64"]' - [darwin]='["x64","arm64"]' - [win32-x64]='["x64"]' -) -declare -A DESCRIPTION=( - [linux-x64]="Upsun CLI binary for Linux x64" - [linux-arm64]="Upsun CLI binary for Linux arm64" - [darwin]="Upsun CLI binary for macOS (universal)" - [win32-x64]="Upsun CLI binary for Windows x64" -) - PLATFORMS=(linux-x64 linux-arm64 darwin win32-x64) +archive_glob_for() { + case "$1" in + linux-x64) echo "upsun_*_linux_amd64.tar.gz" ;; + linux-arm64) echo "upsun_*_linux_arm64.tar.gz" ;; + darwin) echo "upsun_*_darwin_all.tar.gz" ;; + win32-x64) echo "upsun_*_windows_amd64.zip" ;; + *) echo "build.sh: unsupported platform suffix: $1" >&2; exit 1 ;; + esac +} + +bin_name_for() { + case "$1" in + linux-x64|linux-arm64|darwin) echo "upsun" ;; + win32-x64) echo "upsun.exe" ;; + *) echo "build.sh: unsupported platform suffix: $1" >&2; exit 1 ;; + esac +} + +os_json_for() { + case "$1" in + linux-x64|linux-arm64) echo '["linux"]' ;; + darwin) echo '["darwin"]' ;; + win32-x64) echo '["win32"]' ;; + *) echo "build.sh: unsupported platform suffix: $1" >&2; exit 1 ;; + esac +} + +cpu_json_for() { + case "$1" in + linux-x64) echo '["x64"]' ;; + linux-arm64) echo '["arm64"]' ;; + darwin) echo '["x64","arm64"]' ;; + win32-x64) echo '["x64"]' ;; + *) echo "build.sh: unsupported platform suffix: $1" >&2; exit 1 ;; + esac +} + +description_for() { + case "$1" in + linux-x64) echo "Upsun CLI binary for Linux x64" ;; + linux-arm64) echo "Upsun CLI binary for Linux arm64" ;; + darwin) echo "Upsun CLI binary for macOS (universal)" ;; + win32-x64) echo "Upsun CLI binary for Windows x64" ;; + *) echo "build.sh: unsupported platform suffix: $1" >&2; exit 1 ;; + esac +} + if [ -z "${VERSION:-}" ]; then shopt -s nullglob matches=("${DIST_DIR}"/upsun_*_linux_amd64.tar.gz) @@ -84,8 +101,8 @@ mkdir -p "${OUT_DIR}" build_platform_pkg() { local suffix="$1" - local glob="${ARCHIVE_GLOB[$suffix]}" - local bin="${BIN_NAME[$suffix]}" + local glob; glob="$(archive_glob_for "$suffix")" + local bin; bin="$(bin_name_for "$suffix")" local name="@upsun/cli-${suffix}" shopt -s nullglob @@ -106,14 +123,20 @@ build_platform_pkg() { *.zip) unzip -p "${archive}" "${bin}" > "${pkg_dir}/bin/${bin}" ;; *) echo "build.sh: unsupported archive: ${archive}" >&2; exit 1 ;; esac - chmod +x "${pkg_dir}/bin/${bin}" || true + # The exec bit is meaningless on the Windows binary, so a chmod failure + # there is benign; on Unix targets a failure means the binary won't run. + if [ "${suffix}" = "win32-x64" ]; then + chmod +x "${pkg_dir}/bin/${bin}" || true + else + chmod +x "${pkg_dir}/bin/${bin}" + fi sed \ -e "s|__PKG_NAME__|${name}|g" \ -e "s|__VERSION__|${VERSION}|g" \ - -e "s|__DESCRIPTION__|${DESCRIPTION[$suffix]}|g" \ - -e "s|__OS__|${OS_JSON[$suffix]}|g" \ - -e "s|__CPU__|${CPU_JSON[$suffix]}|g" \ + -e "s|__DESCRIPTION__|$(description_for "$suffix")|g" \ + -e "s|__OS__|$(os_json_for "$suffix")|g" \ + -e "s|__CPU__|$(cpu_json_for "$suffix")|g" \ "${NPM_DIR}/platform-template/package.json.tmpl" > "${pkg_dir}/package.json" sed -e "s|__PKG_NAME__|${name}|g" \ diff --git a/npm/scripts/publish.sh b/npm/scripts/publish.sh index 9f387eb7a..a5e8af16b 100755 --- a/npm/scripts/publish.sh +++ b/npm/scripts/publish.sh @@ -40,19 +40,24 @@ if [ ${#all_tarballs[@]} -eq 0 ]; then fi # Classify each tarball by reading its package.json once: the wrapper is -# the one named "upsun"; everything else is a platform package. Cache -# name and version so the propagation wait does not re-open the tarball. -declare -A NAME_OF VERSION_OF +# the one named "upsun"; everything else is a platform package. Names +# and versions for platform tarballs are cached in parallel arrays so +# the propagation wait does not re-open the tarball. Parallel arrays +# rather than associative arrays so this works on macOS's default Bash 3.2. platform_tarballs=() +platform_names=() +platform_versions=() wrapper_tarballs=() for t in "${all_tarballs[@]}"; do pkg_json=$(tar -xzOf "$t" package/package.json) - NAME_OF["$t"]=$(awk -F'"' '/"name":/ { print $4; exit }' <<<"$pkg_json") - VERSION_OF["$t"]=$(awk -F'"' '/"version":/ { print $4; exit }' <<<"$pkg_json") - if [ "${NAME_OF[$t]}" = "upsun" ]; then + name=$(awk -F'"' '/"name":/ { print $4; exit }' <<<"$pkg_json") + version=$(awk -F'"' '/"version":/ { print $4; exit }' <<<"$pkg_json") + if [ "$name" = "upsun" ]; then wrapper_tarballs+=("$t") else platform_tarballs+=("$t") + platform_names+=("$name") + platform_versions+=("$version") fi done @@ -84,8 +89,8 @@ for t in "${platform_tarballs[@]}"; do publish_one "$t"; done if [ "${DRY_RUN}" != "1" ]; then echo "publish.sh: waiting for platform packages to propagate" - for t in "${platform_tarballs[@]}"; do - wait_visible "${NAME_OF[$t]}" "${VERSION_OF[$t]}" + for i in "${!platform_tarballs[@]}"; do + wait_visible "${platform_names[$i]}" "${platform_versions[$i]}" done fi From d79595031c726c479ca81a7b1260f0797d3e5c7f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 1 May 2026 09:19:37 +0100 Subject: [PATCH 3/5] feat(completion): add `completion install` subcommand The deb/rpm/apk packages and the Homebrew formula already drop a completion file, so users who install the CLI those ways get autocompletion automatically. Users on installer.sh and npm have to add `eval "$(upsun completion)"` to their shell rc by hand. Adds an `upsun completion install` subcommand modelled after the equivalents in fly, supabase, and gh: detect the user's shell from $SHELL, render the completion script (via the legacy CLI's existing Symfony-backed `completion ` command), and write it to the standard location for that shell: bash, root: /etc/bash_completion.d/upsun bash, user: ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/upsun zsh, root: /usr/local/share/zsh/site-functions/_upsun zsh, user: ~/.zsh/completions/_upsun Flags --shell, --path, and --print-path cover overrides and dry runs. Writes go through internal/file.Write so the file appears atomically. For zsh, the user-level path may not be in $fpath, so the post-install output prints the snippet to add. PowerShell is out of scope: Symfony Console doesn't emit a PowerShell completion template. Refs CLI-139. Co-Authored-By: Claude Opus 4.7 (1M context) --- commands/completion.go | 200 ++++++++++++++++++++++++++++++++---- commands/completion_test.go | 64 ++++++++++++ 2 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 commands/completion_test.go diff --git a/commands/completion.go b/commands/completion.go index 7fa802233..699d18626 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -2,49 +2,209 @@ package commands import ( "bytes" + "context" "fmt" + "io" + "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/file" +) + +const ( + shellBash = "bash" + shellZsh = "zsh" ) func newCompletionCommand(cnf *config.Config) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "completion", Short: "Print the completion script for your shell", Args: cobra.MaximumNArgs(1), SilenceErrors: true, Run: func(cmd *cobra.Command, args []string) { - // The legacy 5.x CLI uses Symfony's native completion command. - completionArgs := []string{"completion"} + shell := "" if len(args) > 0 { - completionArgs = append(completionArgs, args[0]) + shell = args[0] } - var b bytes.Buffer - c := makeLegacyCLIWrapper(cnf, &b, cmd.ErrOrStderr(), cmd.InOrStdin()) - - if err := c.Exec(cmd.Context(), completionArgs...); err != nil { + script, err := generateCompletionScript(cmd.Context(), cnf, shell, cmd.ErrOrStderr(), cmd.InOrStdin()) + if err != nil { exitWithError(err) } + fmt.Fprintln(cmd.OutOrStdout(), script) + }, + } + cmd.AddCommand(newCompletionInstallCommand(cnf)) + return cmd +} + +// generateCompletionScript runs the legacy CLI's completion command and +// rewrites references to the Phar so the script invokes the wrapper binary. +func generateCompletionScript( + ctx context.Context, cnf *config.Config, shell string, stderr io.Writer, stdin io.Reader, +) (string, error) { + completionArgs := []string{"completion"} + if shell != "" { + completionArgs = append(completionArgs, shell) + } + var b bytes.Buffer + c := makeLegacyCLIWrapper(cnf, &b, stderr, stdin) + if err := c.Exec(ctx, completionArgs...); err != nil { + return "", err + } + pharPath, err := c.PharPath() + if err != nil { + return "", err + } + return strings.ReplaceAll( + strings.ReplaceAll( + b.String(), + pharPath, + cnf.Application.Executable, + ), + filepath.Base(pharPath), + cnf.Application.Executable, + ), nil +} + +func newCompletionInstallCommand(cnf *config.Config) *cobra.Command { + var ( + shellFlag string + pathFlag string + printPath bool + ) + cmd := &cobra.Command{ + Use: "install [shell]", + Short: "Install the shell completion script", + Long: `Install the shell completion script to the standard location for the detected shell. + +Supported shells: bash, zsh. + +The shell is detected from the SHELL environment variable. Override it with the +--shell flag or by passing a positional argument.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + shell := shellFlag + if shell == "" && len(args) > 0 { + shell = args[0] + } + if shell == "" { + shell = detectShell() + } + if shell == "" { + return fmt.Errorf("could not detect shell from $SHELL; pass the shell as an argument or via --shell") + } + switch shell { + case shellBash, shellZsh: + default: + return fmt.Errorf("unsupported shell %q (supported: bash, zsh)", shell) + } + + target := pathFlag + if target == "" { + t, err := defaultCompletionPath(cnf.Application.Executable, shell) + if err != nil { + return err + } + target = t + } + + if printPath { + fmt.Fprintln(cmd.OutOrStdout(), target) + return nil + } - pharPath, err := c.PharPath() + script, err := generateCompletionScript(cmd.Context(), cnf, shell, cmd.ErrOrStderr(), cmd.InOrStdin()) if err != nil { - exitWithError(err) + return err + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", filepath.Dir(target), err) + } + // Completion scripts must be world-readable so other users on multi-user + // systems can source them; they contain no secrets. + if err := file.Write(target, []byte(script), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", target, err) } - completions := strings.ReplaceAll( - strings.ReplaceAll( - b.String(), - pharPath, - cnf.Application.Executable, - ), - filepath.Base(pharPath), - cnf.Application.Executable, - ) - fmt.Fprintln(cmd.OutOrStdout(), completions) + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s completion at %s\n", shell, target) + if note := postInstallNote(shell, target); note != "" { + fmt.Fprintln(cmd.OutOrStdout(), note) + } + return nil }, } + cmd.Flags().StringVar(&shellFlag, "shell", "", "Shell to install completion for (bash or zsh)") + cmd.Flags().StringVar(&pathFlag, "path", "", "Path to write the completion file (overrides the default)") + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the target path without installing") + return cmd +} + +// detectShell returns "bash" or "zsh" if $SHELL points at one of them, or "" otherwise. +func detectShell() string { + sh := os.Getenv("SHELL") + if sh == "" { + return "" + } + switch filepath.Base(sh) { + case shellBash: + return shellBash + case shellZsh: + return shellZsh + } + return "" +} + +// defaultCompletionPath returns the standard install location for the given shell, +// matching what the deb/rpm/apk packages and Homebrew formula already use. +func defaultCompletionPath(binary, shell string) (string, error) { + const ( + systemBashDir = "/etc/bash_completion.d" + systemZshDir = "/usr/local/share/zsh/site-functions" + ) + isRoot := os.Geteuid() == 0 + switch shell { + case shellBash: + if isRoot { + return filepath.Join(systemBashDir, binary), nil + } + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + dataHome = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataHome, "bash-completion", "completions", binary), nil + case shellZsh: + if isRoot { + return filepath.Join(systemZshDir, "_"+binary), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, ".zsh", "completions", "_"+binary), nil + } + return "", fmt.Errorf("unsupported shell %q", shell) +} + +// postInstallNote returns shell-specific instructions printed after a successful install. +func postInstallNote(shell, target string) string { + switch shell { + case shellZsh: + dir := filepath.Dir(target) + return fmt.Sprintf("\nIf %[1]s is not already in your $fpath, add this to your ~/.zshrc:\n\n"+ + " fpath+=(%[1]s)\n autoload -U compinit && compinit\n\n"+ + "Then restart your shell, or run: exec zsh", dir) + case shellBash: + return "\nRestart your shell or run: source " + target + } + return "" } diff --git a/commands/completion_test.go b/commands/completion_test.go new file mode 100644 index 000000000..5d644571a --- /dev/null +++ b/commands/completion_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectShell(t *testing.T) { + cases := []struct { + shell string + want string + }{ + {"/bin/bash", "bash"}, + {"/usr/local/bin/zsh", "zsh"}, + {"/usr/bin/fish", ""}, + {"", ""}, + } + for _, c := range cases { + t.Run(c.shell, func(t *testing.T) { + t.Setenv("SHELL", c.shell) + assert.Equal(t, c.want, detectShell()) + }) + } +} + +func TestDefaultCompletionPath(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("non-root user paths only") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", "") + + cases := []struct { + shell string + want string + }{ + {"bash", filepath.Join(home, ".local", "share", "bash-completion", "completions", "upsun")}, + {"zsh", filepath.Join(home, ".zsh", "completions", "_upsun")}, + } + for _, c := range cases { + t.Run(c.shell, func(t *testing.T) { + got, err := defaultCompletionPath("upsun", c.shell) + assert.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } + + t.Run("xdg override", func(t *testing.T) { + xdg := filepath.Join(home, "xdg") + t.Setenv("XDG_DATA_HOME", xdg) + got, err := defaultCompletionPath("upsun", "bash") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(xdg, "bash-completion", "completions", "upsun"), got) + }) + + t.Run("unsupported shell", func(t *testing.T) { + _, err := defaultCompletionPath("upsun", "fish") + assert.Error(t, err) + }) +} From dd3d7c1167b7489345839442cc0c005e068f4db2 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 1 May 2026 09:19:43 +0100 Subject: [PATCH 4/5] feat(installer): run completion install after raw install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw install path (binary download) doesn't drop a completion file, unlike apt/yum/apk/homebrew. Now that `upsun completion install` exists, installer.sh can call it after the binary is in place. Skipped when: - INSTALL_METHOD is not "raw" (apt/yum/apk/homebrew already cover it) - $INSTALL_NO_COMPLETION is set (opt-out for scripted installs) - is_ci returns true (these paths are unattended by definition) - $SHELL is not bash or zsh (only shells we generate completions for) A failure inside `upsun completion install` is non-fatal — the binary is already installed, so we add a footer note pointing the user at the manual command rather than aborting the whole install. Refs CLI-139. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + installer.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/README.md b/README.md index ad96f8c2a..5272ab0f8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The installer is configurable using the following environment variables: * `INSTALL_METHOD` - force a specific installation method, possible values are `brew` and `raw` * `INSTALL_DIR` - the installation directory for the `raw` installation method, for example you can use `INSTALL_DIR=$HOME/.local/bin` for a single user installation * `VERSION` - the version of the CLI to install, if you need a version other than the latest one +* `INSTALL_NO_COMPLETION` - set to skip installing shell completion (the installer would otherwise run `upsun completion install` after the `raw` install path on bash and zsh) #### Installation configuration examples diff --git a/installer.sh b/installer.sh index 8c4f713b6..bd12ba8eb 100644 --- a/installer.sh +++ b/installer.sh @@ -24,6 +24,9 @@ set -eu # GitHub token check : "${GITHUB_TOKEN:=}" +# Set to skip the post-install `upsun completion install` step +: "${INSTALL_NO_COMPLETION:=}" + # CI specifics : "${CI:=}" : "${BUILD_NUMBER:=}" @@ -623,6 +626,30 @@ is_ci() { fi } +install_completion() { + # apt/yum/apk/homebrew already drop completion files; only the raw path needs this. + if [ "raw" != "${INSTALL_METHOD}" ]; then + return + fi + + if [ ! -z "${INSTALL_NO_COMPLETION}" ] || is_ci; then + return + fi + + case "$(basename "${SHELL:-}")" in + bash|zsh) ;; + *) return ;; + esac + + output "\nInstalling shell completion" "heading" + # $binary is either "upsun" (when dir_bin is in PATH) or a full path + # (set by check_directories), so it works as a command in both cases. + if ! call_user "$binary completion install"; then + add_footer_note " ⚠ Could not install shell completion" \ + " Run later with: $binary completion install" + fi +} + install_raw() { # Start downloading the right version output "\nDownloading the $vendor_name CLI" "heading" @@ -685,4 +712,5 @@ elif [ "apt" = "${INSTALL_METHOD}" ] || [ "yum" = "${INSTALL_METHOD}" ] || [ "ap fi fi install +install_completion outro From c9598ca4ba0beda0a0070bb031da594e323a52c6 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 1 May 2026 09:19:51 +0100 Subject: [PATCH 5/5] feat(npm): print completion-install hint after global install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `npm install -g upsun`, print a one-liner pointing the user at `upsun completion install`. The hint stays silent in three contexts where it would only be noise: - non-global installs (the wrapper as a transitive dependency in some user project), via the npm_config_global env var - non-TTY contexts (npx cache warmups, scripted installs) - CI, or when UPSUN_NO_COMPLETION_HINT is set The postinstall script never modifies any shell config — it only prints — so --ignore-scripts users lose the hint, not functionality. Refs CLI-139. Co-Authored-By: Claude Opus 4.7 (1M context) --- npm/scripts/build.sh | 3 +++ npm/wrapper/README.md | 8 ++++++++ npm/wrapper/bin/postinstall.js | 18 ++++++++++++++++++ npm/wrapper/package.json.tmpl | 4 ++++ 4 files changed, 33 insertions(+) create mode 100644 npm/wrapper/bin/postinstall.js diff --git a/npm/scripts/build.sh b/npm/scripts/build.sh index d1171b4ad..9944ca722 100755 --- a/npm/scripts/build.sh +++ b/npm/scripts/build.sh @@ -156,6 +156,9 @@ build_wrapper_pkg() { cp "${NPM_DIR}/wrapper/bin/upsun.js" "${pkg_dir}/bin/upsun.js" chmod +x "${pkg_dir}/bin/upsun.js" + cp "${NPM_DIR}/wrapper/bin/postinstall.js" "${pkg_dir}/bin/postinstall.js" + chmod +x "${pkg_dir}/bin/postinstall.js" + cp "${NPM_DIR}/wrapper/README.md" "${pkg_dir}/README.md" (cd "${pkg_dir}" && npm pack --pack-destination "${OUT_DIR}" >/dev/null) diff --git a/npm/wrapper/README.md b/npm/wrapper/README.md index 5a79c8881..6b3ddcba6 100644 --- a/npm/wrapper/README.md +++ b/npm/wrapper/README.md @@ -15,6 +15,14 @@ platform-specific binary installed via `optionalDependencies`. On install, npm picks the matching binary for your OS and architecture; nothing is downloaded at runtime. +## Shell completion + +Run the following once to install shell completion (bash and zsh): + +```sh +upsun completion install +``` + ## Source Code, issues, and full documentation live at diff --git a/npm/wrapper/bin/postinstall.js b/npm/wrapper/bin/postinstall.js new file mode 100644 index 000000000..32911eb0d --- /dev/null +++ b/npm/wrapper/bin/postinstall.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// Prints a one-line hint after `npm install -g upsun` to surface the optional +// `upsun completion install` step. Silent for non-global installs (the wrapper +// as a transitive dependency), non-interactive contexts (CI, npx cache +// warmups), and when UPSUN_NO_COMPLETION_HINT is set. Never modifies any +// shell config. + +if (!process.env.npm_config_global) { + process.exit(0); +} +if (!process.stdout.isTTY) { + process.exit(0); +} +if (process.env.CI || process.env.UPSUN_NO_COMPLETION_HINT) { + process.exit(0); +} + +console.log("To enable shell completion, run: upsun completion install"); diff --git a/npm/wrapper/package.json.tmpl b/npm/wrapper/package.json.tmpl index 1301307b5..06ce1afec 100644 --- a/npm/wrapper/package.json.tmpl +++ b/npm/wrapper/package.json.tmpl @@ -11,8 +11,12 @@ "bin": { "upsun": "bin/upsun.js" }, + "scripts": { + "postinstall": "node bin/postinstall.js" + }, "files": [ "bin/upsun.js", + "bin/postinstall.js", "README.md" ], "engines": {