diff --git a/.gitignore b/.gitignore index ce600154..1ec02cca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ internal/legacy/archives/* dist/ +npm/dist/ php-* completion diff --git a/Makefile b/Makefile index ca53b859..01f206c2 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 00000000..6dfaba3a --- /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 00000000..10d45d6a --- /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 00000000..4e96717b --- /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 00000000..d1171b4a --- /dev/null +++ b/npm/scripts/build.sh @@ -0,0 +1,174 @@ +#!/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 + +# 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. +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) + 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; glob="$(archive_glob_for "$suffix")" + local bin; bin="$(bin_name_for "$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 + # 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_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" \ + "${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 00000000..a5e8af16 --- /dev/null +++ b/npm/scripts/publish.sh @@ -0,0 +1,100 @@ +#!/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. 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=$(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 + +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 i in "${!platform_tarballs[@]}"; do + wait_visible "${platform_names[$i]}" "${platform_versions[$i]}" + 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 00000000..5a79c888 --- /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 00000000..d6bf6aa2 --- /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 00000000..1301307b --- /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__" + } +}