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 63a0f88f..d913e545 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/README.md b/README.md index ad96f8c2..5272ab0f 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/commands/completion.go b/commands/completion.go index 7fa80223..699d1862 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 00000000..5d644571 --- /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) + }) +} diff --git a/installer.sh b/installer.sh index 8c4f713b..bd12ba8e 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 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..9944ca72 --- /dev/null +++ b/npm/scripts/build.sh @@ -0,0 +1,177 @@ +#!/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/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) + 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..6b3ddcba --- /dev/null +++ b/npm/wrapper/README.md @@ -0,0 +1,29 @@ +# 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. + +## 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 +[github.com/upsun/cli](https://github.com/upsun/cli). diff --git a/npm/wrapper/bin/postinstall.js b/npm/wrapper/bin/postinstall.js new file mode 100644 index 00000000..32911eb0 --- /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/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..06ce1afe --- /dev/null +++ b/npm/wrapper/package.json.tmpl @@ -0,0 +1,31 @@ +{ + "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" + }, + "scripts": { + "postinstall": "node bin/postinstall.js" + }, + "files": [ + "bin/upsun.js", + "bin/postinstall.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__" + } +}