From bfc25e77227115b04459aaa4660be7de377f49c3 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 25 Sep 2026 12:02:15 -0400 Subject: [PATCH] Ship the CLI with install.sh and GitHub Releases; add agent update, uninstall, and auth Distribution moves off the Homebrew tap. The release workflow now publishes six tarballs (macOS arm64 and x64, Linux arm64 and x64, glibc and musl) plus checksums.txt, and nothing else. install.sh at the repo root downloads the right one, verifies the SHA-256, installs to ~/.local/bin, and adds that directory to PATH (a startup file, or GITHUB_PATH inside Actions). The binary keeps itself current: `agent update` swaps in the latest release atomically (`--to ` for a specific one, `--check` to only look), and a once-a-day background check prints one stderr line when a newer release exists. `agent uninstall` removes the binary and the PATH line, and `--purge` deletes ~/.ellipsis too. Authentication commands move under `agent auth`: `login`, `logout`, and a new `status` that folds `me` and `ping` into one call showing the active host, where the credential came from, and who it belongs to. `agent install` is gone; it only opened the dashboard sign-in page. No hidden aliases for the old spellings, by design. CI runs shellcheck on install.sh and a real install-and-uninstall round trip against a locally built binary served like GitHub Releases. --- .github/homebrew/agent.rb.tmpl | 37 ----- .github/workflows/ci.yml | 21 +++ .github/workflows/release.yml | 47 +------ README.md | 53 ++++--- docs/RELEASING.md | 32 +++-- install.sh | 249 +++++++++++++++++++++++++++++++++ package.json | 2 +- scripts/smoke-local.sh | 8 +- scripts/smoke.sh | 6 +- skills/ellipsis/SKILL.md | 8 +- src/cli.ts | 19 +-- src/commands/auth.ts | 162 +++++++++++++++++++++ src/commands/host.ts | 4 +- src/commands/install.ts | 19 --- src/commands/login.ts | 67 --------- src/commands/me.ts | 38 ----- src/commands/ping.ts | 32 ----- src/commands/uninstall.ts | 63 +++++++++ src/commands/update.ts | 98 +++++++++++++ src/lib/config.ts | 4 +- src/lib/help.ts | 3 +- src/lib/install.ts | 221 +++++++++++++++++++++++++++++ src/lib/output.ts | 8 +- src/lib/update-check.ts | 89 ++++++++++++ src/lib/urls.ts | 9 +- test/auth.test.ts | 120 ++++++---------- test/install.test.ts | 180 ++++++++++++++++++++++++ test/me.test.ts | 47 ------- test/output.test.ts | 6 +- test/urls.test.ts | 14 +- 30 files changed, 1226 insertions(+), 440 deletions(-) delete mode 100644 .github/homebrew/agent.rb.tmpl create mode 100755 install.sh create mode 100644 src/commands/auth.ts delete mode 100644 src/commands/install.ts delete mode 100644 src/commands/login.ts delete mode 100644 src/commands/me.ts delete mode 100644 src/commands/ping.ts create mode 100644 src/commands/uninstall.ts create mode 100644 src/commands/update.ts create mode 100644 src/lib/install.ts create mode 100644 src/lib/update-check.ts create mode 100644 test/install.test.ts delete mode 100644 test/me.test.ts diff --git a/.github/homebrew/agent.rb.tmpl b/.github/homebrew/agent.rb.tmpl deleted file mode 100644 index f8be1ba..0000000 --- a/.github/homebrew/agent.rb.tmpl +++ /dev/null @@ -1,37 +0,0 @@ -# Auto-generated by the ellipsis-dev/cli release workflow. Do not edit by hand. -class Agent < Formula - desc "Ellipsis agent CLI — drive the Ellipsis cloud from your terminal" - homepage "https://ellipsis.dev" - version "__VERSION__" - license "MIT" - - on_macos do - on_arm do - url "https://github.com/ellipsis-dev/cli/releases/download/v__VERSION__/agent-darwin-arm64.tar.gz" - sha256 "__SHA_DARWIN_ARM64__" - end - on_intel do - url "https://github.com/ellipsis-dev/cli/releases/download/v__VERSION__/agent-darwin-x64.tar.gz" - sha256 "__SHA_DARWIN_X64__" - end - end - - on_linux do - on_arm do - url "https://github.com/ellipsis-dev/cli/releases/download/v__VERSION__/agent-linux-arm64.tar.gz" - sha256 "__SHA_LINUX_ARM64__" - end - on_intel do - url "https://github.com/ellipsis-dev/cli/releases/download/v__VERSION__/agent-linux-x64.tar.gz" - sha256 "__SHA_LINUX_X64__" - end - end - - def install - bin.install "agent" - end - - test do - assert_match version.to_s, shell_output("#{bin}/agent --version") - end -end diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 745b5e9..5cdfb93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,29 @@ jobs: - run: bun run typecheck - run: bun run test - run: bun run build + - run: shellcheck install.sh # Compile-smoke: the release Bun-compiles a single binary, which bundles # everything (unlike tsup) and can fail where `build` passes. Catch that # here so a release tag never breaks on it. - run: bun build src/cli.ts --compile --outfile /tmp/agent - run: /tmp/agent --version + # Install-smoke: run install.sh for real against the binary above, laid + # out the way GitHub Releases serves it, then let the binary remove + # itself. GITHUB_PATH is unset so the startup-file branch is exercised. + - name: install.sh and agent uninstall round trip + run: | + set -euo pipefail + site="$RUNNER_TEMP/releases/latest/download" + mkdir -p "$site" "$RUNNER_TEMP/home" + tar -czf "$site/agent-linux-x64.tar.gz" -C /tmp agent + (cd "$site" && sha256sum agent-linux-x64.tar.gz > checksums.txt) + python3 -m http.server 8123 --directory "$RUNNER_TEMP/releases" >/dev/null 2>&1 & + sleep 1 + env -u GITHUB_PATH HOME="$RUNNER_TEMP/home" SHELL=/bin/bash \ + ELLIPSIS_DOWNLOAD_BASE=http://127.0.0.1:8123 \ + sh install.sh --dir "$RUNNER_TEMP/bin" + "$RUNNER_TEMP/bin/agent" --version + grep -q "Ellipsis agent installer" "$RUNNER_TEMP/home/.bashrc" + HOME="$RUNNER_TEMP/home" "$RUNNER_TEMP/bin/agent" uninstall + test ! -e "$RUNNER_TEMP/bin/agent" + ! grep -q "Ellipsis agent installer" "$RUNNER_TEMP/home/.bashrc" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4e6d10..7734ff9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,11 +47,14 @@ jobs: bun run typecheck bun run test + # One tarball per target. install.sh and `agent update` pick the one for + # the machine they run on, so the list here must match RELEASE_TARGETS in + # src/lib/install.ts (test/install.test.ts checks). - name: Build platform binaries run: | set -euo pipefail mkdir -p dist - for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl; do echo "::group::build $t" bun build src/cli.ts --compile --target=bun-"$t" --outfile agent tar -czf "dist/agent-$t.tar.gz" agent @@ -59,16 +62,13 @@ jobs: echo "::endgroup::" done + # checksums.txt is what install.sh and `agent update` verify against. - name: Compute checksums - id: sha run: | set -euo pipefail cd dist - for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do - sum=$(sha256sum "agent-$t.tar.gz" | cut -d' ' -f1) - echo "${t//-/_}=$sum" >> "$GITHUB_OUTPUT" - done sha256sum *.tar.gz > checksums.txt + cat checksums.txt - name: Create GitHub release uses: softprops/action-gh-release@v3 @@ -77,38 +77,3 @@ jobs: files: | dist/*.tar.gz dist/checksums.txt - - - name: Check out the Homebrew tap - uses: actions/checkout@v7 - with: - repository: ellipsis-dev/homebrew-cli - # Write-scoped deploy key for the tap repo only (the workflow's own - # GITHUB_TOKEN can't reach a second repo). checkout configures the SSH - # remote + key, so the push step below authenticates over SSH. - ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} - path: tap - - - name: Regenerate the formula - run: | - set -euo pipefail - sed \ - -e "s|__VERSION__|${{ steps.version.outputs.version }}|g" \ - -e "s|__SHA_DARWIN_ARM64__|${{ steps.sha.outputs.darwin_arm64 }}|g" \ - -e "s|__SHA_DARWIN_X64__|${{ steps.sha.outputs.darwin_x64 }}|g" \ - -e "s|__SHA_LINUX_ARM64__|${{ steps.sha.outputs.linux_arm64 }}|g" \ - -e "s|__SHA_LINUX_X64__|${{ steps.sha.outputs.linux_x64 }}|g" \ - .github/homebrew/agent.rb.tmpl > tap/Formula/agent.rb - - - name: Commit and push the formula - run: | - set -euo pipefail - cd tap - git config user.name "ellipsis-bot" - git config user.email "bot@ellipsis.dev" - git add Formula/agent.rb - if git diff --cached --quiet; then - echo "Formula unchanged; nothing to push." - else - git commit -m "agent ${{ steps.version.outputs.version }}" - git push - fi diff --git a/README.md b/README.md index e793f13..9b7c84f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,28 @@ authenticates, opens a WebSocket, and streams results. It is open source ## Install ```sh -brew install ellipsis-dev/cli/agent +curl -fsSL https://raw.githubusercontent.com/ellipsis-dev/cli/main/install.sh | sh ``` +The script downloads the binary for your OS and CPU from GitHub Releases, +checks its SHA-256, and puts it at `~/.local/bin/agent`. If that directory is +not on your PATH, it appends one line to your shell's startup file +(`--no-modify-path` to skip that). Pin a version with `ELLIPSIS_VERSION=2.30.0` +or `sh -s -- --version 2.30.0`; choose the directory with `--dir`. + +In CI the same line works: inside GitHub Actions the directory is added to +`GITHUB_PATH`, and in a container `--dir /usr/local/bin` skips PATH setup +entirely. Alpine images get the musl build automatically. + +```sh +agent update # replace the binary with the latest release (--check to only look) +agent uninstall # remove the binary and the PATH line (--purge to delete ~/.ellipsis too) +``` + +An installed binary checks for a newer release once a day, in the background, +and prints one line on stderr when it finds one. `ELLIPSIS_NO_UPDATE_CHECK=1` +turns that off; it is already off when `CI` is set or stderr is not a terminal. + ## Teach your coding agent about Ellipsis [`skills/ellipsis`](skills/ellipsis/SKILL.md) is an @@ -39,10 +58,9 @@ skills: ## Usage ```sh -agent install # open the dashboard sign-in page, where you install Ellipsis -agent login # device-code auth against the active host -agent logout # remove stored credentials (--all for every host) -agent me # show the current credential's identity +agent auth login # device-code auth against the active host +agent auth logout # remove stored credentials (--all for every host) +agent auth status # active host, where the token came from, and who you are agent host list # list configured hosts (the active one is marked *) agent host add beta https://beta-api.ellipsis.dev # add a host and switch to it @@ -104,7 +122,8 @@ agent usage # usage dashboard for the period agent analytics reviewer --account-type bot # which apps review the most PRs agent analytics pr --days 30 # PR volume/trend with human vs bot splits agent analytics review --repo my-service # review totals + top reviewers -agent ping # check authenticated API connectivity +agent update # update the CLI to the latest release (--to for a specific one) +agent uninstall # remove the CLI from this machine (--purge to delete ~/.ellipsis too) ``` Every command shown is singular. The plural spelling of each (`agent files`, @@ -126,7 +145,7 @@ prints a clickable dashboard link. How the stream works is described in ### Auth -`agent login` uses the device-code flow: it requests a code pair, prints a +`agent auth login` uses the device-code flow: it requests a code pair, prints a verification URL (and opens it unless `--no-browser`), and polls until you approve the request in the dashboard. The issued user token is stored under `~/.ellipsis/config.json` (mode 0600) and attributes sessions to you. @@ -136,8 +155,7 @@ environment (`ELLIPSIS_API_TOKEN` / `ELLIPSIS_API_BASE_URL`, with the legacy `ELLIPSIS_API_BASE` accepted as a fallback) → the **active host** in the config file → default (prod). This lets the CLI run headlessly — e.g. inside an Ellipsis cloud sandbox where a per-sandbox token and base URL are injected into -the environment — with no `agent login` and no config file on disk. `agent -logout` only clears the on-disk token (`--all` for every host); a token supplied +the environment — with no `agent auth login` and no config file on disk. `agent auth logout` only clears the on-disk token (`--all` for every host); a token supplied via `ELLIPSIS_API_TOKEN` lives in the environment and keeps working until you unset it. @@ -151,8 +169,7 @@ them all (the active one marked `*`). Each host keeps its own token (so switching doesn't re-authenticate) and its own dashboard/app URL. The app URL is derived from the API URL by default (`api.` → `app.`); a self-hosted instance whose dashboard host isn't a mechanical swap sets it explicitly with `agent host -add … --app-base ` (or `agent host set --app-base `). `agent -login` then authenticates the active host, and every link the CLI prints points +add … --app-base ` (or `agent host set --app-base `). `agent auth login` then authenticates the active host, and every link the CLI prints points at that host's dashboard. Hosts and tokens live in `~/.ellipsis/config.json` (mode 0600); set @@ -228,20 +245,16 @@ npm run compile # single-binary build (bun) ### Releasing Pushing a `v*` tag triggers `.github/workflows/release.yml`, which Bun-compiles -binaries for macOS and Linux (arm64 + x64), publishes a GitHub release with the -tarballs, and regenerates the formula in -[`ellipsis-dev/homebrew-cli`](https://github.com/ellipsis-dev/homebrew-cli). +one binary per target (macOS arm64 and x64, Linux arm64 and x64, both glibc +and musl), and publishes a GitHub release with the tarballs and a +`checksums.txt`. `install.sh` and `agent update` download from that release, +so publishing it is the whole distribution step. See +[`docs/RELEASING.md`](docs/RELEASING.md). ```sh git tag v2.30.0 && git push origin v2.30.0 ``` -The cross-repo push to the tap uses a write-scoped **deploy key**: the public -half is registered on `ellipsis-dev/homebrew-cli` (Settings → Deploy keys, write -access), and the private half is stored as the `HOMEBREW_TAP_DEPLOY_KEY` secret -on this repo. The workflow checks out the tap over SSH with it. A deploy key is -scoped to that one repo only — no account-wide PAT involved. - ### Status The full public REST surface (auth, sessions, session steps, configs, diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 39f4de5..28944f4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,8 +1,10 @@ # Releasing the CLI (maintainers) -The CLI ships only as a Homebrew formula from the `ellipsis-dev/homebrew-cli` -tap. It is never published to npm: `package.json` is `private`, has no `bin`, -and there is no `publishConfig`. +The CLI ships only as Bun-compiled binaries on GitHub Releases. Users install +with `install.sh` (`curl -fsSL https://raw.githubusercontent.com/ellipsis-dev/cli/main/install.sh | sh`) +and stay current with `agent update`. Both download from the release assets +and verify them against `checksums.txt`. It is never published to npm: +`package.json` is `private`, has no `bin`, and there is no `publishConfig`. CLI **2.X.Y** always uses SDK **0.X.Y**. For example, CLI **2.30.0** uses `@ellipsis-dev/sdk` **0.30.0**. Keep the CLI version and exact SDK dependency @@ -17,12 +19,16 @@ takes a version input in the Actions UI). On a tag push it: 1. Installs dependencies with the frozen lockfile, checks that the release version matches `package.json` and the installed SDK follows the version rule, then runs typechecking and tests. Mismatches stop the release. -2. Cross-compiles four binaries (`darwin-arm64`, `darwin-x64`, `linux-x64`, - `linux-arm64`) with `bun build --compile`, tars each, and computes SHA-256 - checksums. -3. Creates the GitHub release with the tarballs and `checksums.txt`. -4. Regenerates `Formula/agent.rb` in the tap repo from the template and pushes - it, so `brew install ellipsis-dev/cli/agent` picks up the new version. +2. Cross-compiles six binaries (`darwin-arm64`, `darwin-x64`, `linux-x64`, + `linux-arm64`, `linux-x64-musl`, `linux-arm64-musl`) with + `bun build --compile` and tars each. The list lives in the workflow and in + `RELEASE_TARGETS` in `src/lib/install.ts`; a test keeps them equal. +3. Writes `checksums.txt` (`sha256sum` output) and creates the GitHub release + with the tarballs and that file. + +Nothing else needs to happen: `install.sh` resolves the newest release through +GitHub's `releases/latest/download/` redirect, and installed binaries learn +about it from their daily background check. The manual steps (Hunter cuts releases) are: commit the version updates and SDK migration, ensure CI is green, then create and push the matching `v2.X.Y` @@ -34,3 +40,11 @@ and releases. `bun run compile` checks the CLI/SDK pair before building; `./agent --version` reports `2.30.0` for this version, including local builds. Run `bun run check:versions` to check the pair without building, or `bun run check:versions 2.30.0` to also validate an intended release version. + +## Trying the installer without a release + +CI runs `install.sh` against a locally built binary served from a temporary +directory laid out like GitHub Releases (`latest/download/` plus +`checksums.txt`), by pointing `ELLIPSIS_DOWNLOAD_BASE` at it. The same trick +works on a laptop with `python3 -m http.server`; see the install-smoke step in +`.github/workflows/ci.yml`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..96ff561 --- /dev/null +++ b/install.sh @@ -0,0 +1,249 @@ +#!/bin/sh +# Installs the Ellipsis agent CLI. +# +# curl -fsSL https://raw.githubusercontent.com/ellipsis-dev/cli/main/install.sh | sh +# +# Options (pass them after `sh -s --`): +# --version install that release instead of the latest one +# --dir where to put the binary (default: ~/.local/bin) +# --no-modify-path leave shell startup files alone; print the PATH line instead +# --help print the usage text +# +# Environment variables do the same: ELLIPSIS_VERSION, ELLIPSIS_INSTALL_DIR, +# ELLIPSIS_NO_MODIFY_PATH=1. +# +# What it does: picks the release asset for this OS and CPU, downloads it and +# checksums.txt from GitHub Releases, checks the SHA-256, and puts the binary +# at /agent. If is not on PATH it appends one marked line to the +# startup file of $SHELL; inside GitHub Actions it appends to $GITHUB_PATH +# instead. `agent update` and `agent uninstall` take it from there. +# +# Needs: curl or wget, tar, and one of sha256sum, shasum, or openssl. +set -eu + +RELEASES="${ELLIPSIS_DOWNLOAD_BASE:-https://github.com/ellipsis-dev/cli/releases}" +BIN="agent" +# Keep in sync with PATH_MARKER in src/lib/install.ts: `agent uninstall` +# removes exactly the startup-file lines that carry this comment. +PATH_MARKER="Ellipsis agent installer" + +VERSION="${ELLIPSIS_VERSION:-}" +INSTALL_DIR="${ELLIPSIS_INSTALL_DIR:-$HOME/.local/bin}" +MODIFY_PATH=1 +if [ -n "${ELLIPSIS_NO_MODIFY_PATH:-}" ]; then MODIFY_PATH=0; fi + +say() { printf '%s\n' "$*"; } +fail() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1; } + +usage() { + cat <<'USAGE' +Usage: install.sh [--version ] [--dir ] [--no-modify-path] + + --version install that release instead of the latest one + --dir where to put the binary (default: ~/.local/bin) + --no-modify-path leave shell startup files alone; print the PATH line instead + +Environment: ELLIPSIS_VERSION, ELLIPSIS_INSTALL_DIR, ELLIPSIS_NO_MODIFY_PATH=1 +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + --version=*) VERSION="${1#--version=}" ;; + --version) + if [ $# -lt 2 ]; then fail "--version needs a value"; fi + VERSION="$2" + shift + ;; + --dir=*) INSTALL_DIR="${1#--dir=}" ;; + --dir) + if [ $# -lt 2 ]; then fail "--dir needs a value"; fi + INSTALL_DIR="$2" + shift + ;; + --no-modify-path) MODIFY_PATH=0 ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown option: $1" + ;; + esac + shift +done + +# --- tools ------------------------------------------------------------------- + +if ! need tar; then fail "tar is required"; fi + +if need curl; then + download() { curl -fsSL --retry 3 -o "$2" "$1"; } +elif need wget; then + download() { wget -q -O "$2" "$1"; } +else + fail "curl or wget is required" +fi + +sha256_of() { + if need sha256sum; then + sha256sum "$1" | awk '{ print $1 }' + elif need shasum; then + shasum -a 256 "$1" | awk '{ print $1 }' + elif need openssl; then + openssl dgst -sha256 "$1" | awk '{ print $NF }' + else + fail "sha256sum, shasum, or openssl is required to verify the download" + fi +} + +# --- which build ------------------------------------------------------------- + +os=$(uname -s) +case "$os" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) fail "unsupported operating system: $os (macOS and Linux only)" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=x64 ;; + arm64 | aarch64) arch=arm64 ;; + *) fail "unsupported CPU: $arch" ;; +esac + +# A shell running under Rosetta reports x86_64 on an Apple Silicon Mac. +if [ "$os" = darwin ] && [ "$arch" = x64 ]; then + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + arch=arm64 + fi +fi + +# musl (Alpine and friends) needs its own build; its dynamic loader is the tell. +libc="" +if [ "$os" = linux ]; then + for f in /lib/ld-musl-*.so.1; do + if [ -e "$f" ]; then libc="-musl"; fi + done +fi + +target="$os-$arch$libc" +tarball="$BIN-$target.tar.gz" + +if [ -n "$VERSION" ]; then + VERSION="${VERSION#v}" + base="$RELEASES/download/v$VERSION" + label="$VERSION" +else + base="$RELEASES/latest/download" + label="latest" +fi + +# --- download and verify ----------------------------------------------------- + +tmp=$(mktemp -d 2>/dev/null || mktemp -d -t "$BIN") +trap 'rm -rf "$tmp"' EXIT + +say "Downloading $BIN $label for $target..." +if ! download "$base/$tarball" "$tmp/$tarball"; then + fail "could not download $base/$tarball (is $label a published release with a $target build?)" +fi +if ! download "$base/checksums.txt" "$tmp/checksums.txt"; then + fail "could not download $base/checksums.txt" +fi + +expected=$(grep "[[:space:]]$tarball\$" "$tmp/checksums.txt" | awk '{ print $1 }') +if [ -z "$expected" ]; then fail "checksums.txt has no entry for $tarball"; fi +actual=$(sha256_of "$tmp/$tarball") +if [ "$actual" != "$expected" ]; then + fail "checksum mismatch for $tarball: expected $expected, got $actual" +fi + +tar -xzf "$tmp/$tarball" -C "$tmp" +if [ ! -f "$tmp/$BIN" ]; then fail "$tarball does not contain $BIN"; fi + +# --- install ----------------------------------------------------------------- + +if ! mkdir -p "$INSTALL_DIR"; then fail "cannot create $INSTALL_DIR (try --dir, or sudo)"; fi +if [ -e "$INSTALL_DIR/$BIN" ] || [ -L "$INSTALL_DIR/$BIN" ]; then + say "Replacing the existing $INSTALL_DIR/$BIN" +fi + +# Stage next to the destination so the final rename is atomic, even over a +# binary that is running right now. +staged="$INSTALL_DIR/.$BIN.install.$$" +if ! cp "$tmp/$BIN" "$staged" || ! chmod 755 "$staged" || ! mv -f "$staged" "$INSTALL_DIR/$BIN"; then + rm -f "$staged" + fail "cannot write to $INSTALL_DIR (try --dir, or sudo)" +fi + +if ! installed=$("$INSTALL_DIR/$BIN" --version 2>/dev/null); then + fail "$INSTALL_DIR/$BIN does not run on this machine" +fi +say "Installed $BIN $installed to $INSTALL_DIR/$BIN" + +# --- PATH -------------------------------------------------------------------- + +case ":$PATH:" in + *":$INSTALL_DIR:"*) on_path=1 ;; + *) on_path=0 ;; +esac + +# Write $HOME symbolically so the line survives a home directory move. +case "$INSTALL_DIR" in + "$HOME"/*) dir_expr="\$HOME${INSTALL_DIR#"$HOME"}" ;; + *) dir_expr="$INSTALL_DIR" ;; +esac +export_line="export PATH=\"$dir_expr:\$PATH\"" + +append_line() { + mkdir -p "$(dirname "$1")" + if [ -f "$1" ] && grep -qF "$PATH_MARKER" "$1"; then return 0; fi + printf '\n%s\n' "$2" >>"$1" + say "Added $INSTALL_DIR to PATH in $1" +} + +if [ "$on_path" = 1 ]; then + resolved=$(command -v "$BIN" 2>/dev/null || true) + if [ -n "$resolved" ] && [ "$resolved" != "$INSTALL_DIR/$BIN" ]; then + say "Note: $resolved comes before $INSTALL_DIR on your PATH, so \`$BIN\` still runs that one." + fi +elif [ -n "${GITHUB_PATH:-}" ]; then + echo "$INSTALL_DIR" >>"$GITHUB_PATH" + say "Added $INSTALL_DIR to GITHUB_PATH, so later steps can run $BIN." +elif [ "$MODIFY_PATH" = 0 ]; then + say "$INSTALL_DIR is not on your PATH. Add this line to your shell startup file:" + say " $export_line" +else + hint="$export_line" + case "$(basename "${SHELL:-sh}")" in + zsh) append_line "$HOME/.zshrc" "$export_line # $PATH_MARKER" ;; + fish) + hint="set -gx PATH $dir_expr \$PATH" + append_line "$HOME/.config/fish/config.fish" "$hint # $PATH_MARKER" + ;; + bash) + append_line "$HOME/.bashrc" "$export_line # $PATH_MARKER" + if [ "$os" = darwin ]; then + # Terminal.app opens login shells, which read the first of these that + # exists (bash's own order). Create .bash_profile only when none exist, + # so an existing .profile keeps being read. + login_rc="$HOME/.bash_profile" + for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + if [ -f "$f" ]; then + login_rc="$f" + break + fi + done + append_line "$login_rc" "$export_line # $PATH_MARKER" + fi + ;; + *) append_line "$HOME/.profile" "$export_line # $PATH_MARKER" ;; + esac + say "Open a new shell, or run now: $hint" +fi + +say "Next: run \`$BIN auth login\` to authenticate." diff --git a/package.json b/package.json index 0b7707d..cf2126d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "type": "module", "packageManager": "bun@1.3.14", - "//": "Not an npm package. The CLI ships only as a Bun-compiled single binary via the Homebrew tap (see .github/workflows/release.yml). `private` blocks accidental `npm publish`; there is deliberately no `bin` so `npm i -g .` can't install a stale global that shadows the brew binary.", + "//": "Not an npm package. The CLI ships only as a Bun-compiled single binary on GitHub Releases, installed by install.sh (see .github/workflows/release.yml). `private` blocks accidental `npm publish`; there is deliberately no `bin` so `npm i -g .` can't install a stale global that shadows the installed binary.", "private": true, "engines": { "node": ">=22" diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index 67e47ae..833ee98 100755 --- a/scripts/smoke-local.sh +++ b/scripts/smoke-local.sh @@ -3,7 +3,7 @@ # Fully-automated end-to-end smoke test against a LOCAL docker compose backend. # # Unlike scripts/smoke.sh (which waits for you to approve the login by hand), -# this drives the whole device-code flow itself: it starts `agent login`, +# this drives the whole device-code flow itself: it starts `agent auth login`, # scrapes the user code, and approves it headlessly by calling the cli_auth # service inside the running `public_api` container — then exercises the # authenticated API surface. Uses a throwaway config dir, so your real token is @@ -61,7 +61,7 @@ echo "Approving as customer: $CUSTOMER_ID" echo echo "== Starting login (device-code flow) ==" -npx tsx src/cli.ts login --no-browser >"$LOGIN_OUT" 2>&1 & +npx tsx src/cli.ts auth login --no-browser >"$LOGIN_OUT" 2>&1 & LOGIN_PID=$! # Wait for the CLI to print the verification code (format XXXX-XXXX). @@ -100,7 +100,7 @@ cat "$LOGIN_OUT" echo echo "== Authenticated API calls ==" -run me +run auth status run budget run usage run config list @@ -108,7 +108,7 @@ run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout -if npx tsx src/cli.ts me 2>/dev/null; then +if npx tsx src/cli.ts auth status 2>/dev/null; then echo "UNEXPECTED: 'me' succeeded after logout" >&2 exit 1 fi diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 27cc60b..899af90 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -37,10 +37,10 @@ echo "Config dir: $CONFIG_DIR (temporary)" echo echo "== Logging in (approve the printed request, then this continues) ==" -npx tsx src/cli.ts login --no-browser +npx tsx src/cli.ts auth login --no-browser echo "== Authenticated API calls ==" -run me +run auth status run budget run usage run config list @@ -48,7 +48,7 @@ run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout -if npx tsx src/cli.ts me; then +if npx tsx src/cli.ts auth status; then echo "UNEXPECTED: 'me' succeeded after logout" >&2 exit 1 else diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 1d87c79..6a34a0a 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -372,11 +372,9 @@ the dashboard uses. Most commands accept `--json` for the raw API response, which makes it as comfortable for a coding agent as for a human. ```sh -brew install ellipsis-dev/cli/agent -agent install # opens the dashboard page that installs the GitHub app -agent login # device-code flow tied to your GitHub identity -agent ping # confirms the API is reachable and the credential is valid -agent me # the identity behind the current credential +curl -fsSL https://raw.githubusercontent.com/ellipsis-dev/cli/main/install.sh | sh +agent auth login # device-code flow tied to your GitHub identity +agent auth status # the active host, the credential source, and who you are ``` In CI or any headless environment, skip the login: create an API key in the diff --git a/src/cli.ts b/src/cli.ts index 03d3ee6..94e4fb3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,6 @@ import { Command } from 'commander' -import { registerInstall } from './commands/install' -import { registerLogin } from './commands/login' +import { registerAuth } from './commands/auth' import { registerHost } from './commands/host' -import { registerMe } from './commands/me' import { registerSession } from './commands/session' import { registerReview } from './commands/review' import { registerAutomation } from './commands/automation' @@ -18,11 +16,13 @@ import { registerLinear } from './commands/linear' import { registerSentry } from './commands/sentry' import { registerUsage } from './commands/usage' import { registerAnalytics } from './commands/analytics' -import { registerPing } from './commands/ping' +import { registerUpdate } from './commands/update' +import { registerUninstall } from './commands/uninstall' import { registerHelp } from './commands/help' import { commandTypoMessage, looksLikeCommandTypo } from './lib/args' import { VERSION } from './lib/constants' import { configureCliHelp } from './lib/help' +import { maybeNudgeUpdate } from './lib/update-check' const program = new Command() @@ -35,10 +35,8 @@ program // rendering (sorted, alias-free, grouped at the top level). configureCliHelp(program) -registerInstall(program) -registerLogin(program) +registerAuth(program) registerHost(program) -registerMe(program) registerSession(program) registerReview(program) registerAutomation(program) @@ -54,7 +52,8 @@ registerLinear(program) registerSentry(program) registerUsage(program) registerAnalytics(program) -registerPing(program) +registerUpdate(program) +registerUninstall(program) registerHelp(program) // A bare `agent` prints the top-level help, the same page as `agent --help`. @@ -76,6 +75,10 @@ const topLevelCommands = new Set([ // Hidden plural aliases dispatch, but a "did you mean" hint should only ever // name the spelling we document. const suggestableCommands = ['help', ...program.commands.map((c) => c.name())] +// One stderr line when a newer release is known, plus the daily background +// check that learns about it. Never blocks, never fails the command. +maybeNudgeUpdate(process.argv) + const first = process.argv[2] const isTopLevel = first === '-h' || diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..ce96469 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,162 @@ +import { join } from 'node:path' +import type { Command } from 'commander' +import { api, APIError } from '../lib/api' +import { deviceLogin, openBrowser, persistToken } from '../lib/auth' +import { + activeHost, + activeHostName, + clearActiveHostToken, + clearAllTokens, + configDir, + envToken, + resolveApiBase, + resolveAppBase, +} from '../lib/config' +import { apiRoutes } from '../lib/help' +import { printJson } from '../lib/output' +import type { WhoAmI } from '../lib/types' +import { cliAuthUrl } from '../lib/urls' + +// Where the credential the CLI is about to use came from. Mirrors the +// precedence in resolveToken: the environment beats the config file. +export type TokenSource = 'env' | 'config' | 'none' + +export function tokenSource(envSet: boolean, stored: boolean): TokenSource { + if (envSet) return 'env' + if (stored) return 'config' + return 'none' +} + +// One line per fact the server knows about the credential. Prefers the GitHub +// login over the bare numeric user id when the server resolved a gh_user. +export function renderIdentity(me: WhoAmI): void { + console.log(`customer: ${me.customer_login} (${me.customer_id})`) + if (me.gh_user) console.log(`user: ${me.gh_user.login} (${me.user_id})`) + else if (me.user_id) console.log(`user: ${me.user_id}`) + if (me.api_key_id) console.log(`api key: ${me.api_key_id}`) + if (me.sandbox_id) console.log(`sandbox: ${me.sandbox_id}`) +} + +export function registerAuth(program: Command): void { + const auth = program + .command('auth') + .description('Log in, log out, and check the credential the CLI is using') + + auth + .command('login') + .description('Authenticate against the active host via the device-code flow') + .option('--no-browser', 'do not auto-open the verification URL (for headless or SSH)') + .action(async (opts: { browser?: boolean }) => { + try { + const { token } = await deviceLogin(api(), { + onPrompt: (start) => { + // Build the approval URL from the app base of the host the CLI is + // pointed at, NOT the server's verification_uri_complete: the + // backend defaults that to prod, so a beta or self-hosted login + // would otherwise be sent to the prod dashboard, where the code + // can't be approved. See cliAuthUrl and resolveAppBase. + const verificationUrl = cliAuthUrl(resolveAppBase(), start.user_code) + console.log('To authenticate, open this URL and approve the request:') + console.log(` ${verificationUrl}`) + console.log(`Verification code: ${start.user_code}`) + if (opts.browser !== false) { + openBrowser(verificationUrl) + } + console.log('Waiting for approval…') + }, + }) + persistToken(token) + console.log('✓ Logged in.') + } catch (err) { + console.error(`login failed: ${(err as Error).message}`) + process.exitCode = 1 + } + }) + + auth + .command('logout') + .description("Remove the active host's stored token, or every host's with --all") + .option('--all', 'clear the stored token for every host, not just the active one') + .action((opts: { all?: boolean }) => { + // Clear only the on-disk token(s); the host entries (api/app base) stay + // so the next `agent auth login` targets the same instance. + if (opts.all) { + clearAllTokens() + } else { + clearActiveHostToken() + } + // A token supplied via ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives + // in the environment and keeps working: don't claim to have cleared what + // we can't. + const envNote = process.env.ELLIPSIS_API_TOKEN + ? ' ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.' + : '' + const scope = opts.all ? 'all hosts' : (activeHostName() ?? 'the active host') + console.log(`Removed stored credentials for ${scope}.${envNote}`) + }) + + apiRoutes( + auth + .command('status') + .description('Show the active host, where the credential came from, and who it belongs to'), + 'GET /v1/identity', + ) + .option('--json', 'output raw JSON') + .action(async (opts: { json?: boolean }) => { + await status(opts.json === true) + }) +} + +// Exit 1 whenever the CLI could not make an authenticated call, so a CI step +// can use `agent auth status` as its readiness check. +async function status(json: boolean): Promise { + const host = activeHostName() ?? null + const apiBase = resolveApiBase() + const source = tokenSource(envToken() !== undefined, activeHost()?.token !== undefined) + const sourceLabel = + source === 'env' + ? 'ELLIPSIS_API_TOKEN' + : source === 'config' + ? join(configDir(), 'config.json') + : 'none' + + let identity: WhoAmI | null = null + let error: string | undefined + if (source === 'none') { + error = 'Not logged in. Run `agent auth login`, or set ELLIPSIS_API_TOKEN.' + } else { + try { + identity = await api().identity() + } catch (err) { + if (err instanceof APIError && err.status === 401) { + error = + source === 'env' + ? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent auth login`.' + : 'The stored token is invalid or has expired. Run `agent auth login` again.' + } else if (err instanceof APIError) { + error = `${err.status} ${err.message}` + } else { + // Network, DNS, or connection failure: never got an HTTP response. + error = `cannot reach ${apiBase}: ${(err as Error).message}` + } + } + } + + if (json) { + printJson({ + host, + api_base: apiBase, + token_source: source, + authenticated: identity !== null, + identity, + ...(error ? { error } : {}), + }) + } else { + console.log(`host: ${host ?? 'none'}`) + console.log(`api: ${apiBase}`) + console.log(`token: ${sourceLabel}`) + if (identity) renderIdentity(identity) + if (error) console.error(error) + } + if (!identity) process.exitCode = 1 +} diff --git a/src/commands/host.ts b/src/commands/host.ts index fa469e1..7e1f0c1 100644 --- a/src/commands/host.ts +++ b/src/commands/host.ts @@ -51,7 +51,7 @@ export function registerHost(program: Command): void { host .command('add ') - .description('Add a host and switch to it, then run `agent login` to authenticate') + .description('Add a host and switch to it, then run `agent auth login` to authenticate') .option( '--app-base ', 'dashboard URL for building links / login (default: derived from the API URL)', @@ -59,7 +59,7 @@ export function registerHost(program: Command): void { .action((name: string, apiUrl: string, opts: { appBase?: string }) => { addHost(name, requireUrl(apiUrl, 'api-url'), opts.appBase && requireUrl(opts.appBase, '--app-base')) console.log(`✓ added host "${name}", now active`) - console.log('Run `agent login` to authenticate against it.') + console.log('Run `agent auth login` to authenticate against it.') }) host diff --git a/src/commands/install.ts b/src/commands/install.ts deleted file mode 100644 index 92bb0a1..0000000 --- a/src/commands/install.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Command } from 'commander' -import { resolveAppBase } from '../lib/config' -import { openBrowser } from '../lib/auth' -import { appLoginUrl } from '../lib/urls' - -export function registerInstall(program: Command): void { - program - .command('install') - .description('Open the dashboard sign-in page, where you install Ellipsis') - .option('--no-browser', 'print the URL without opening a browser (for headless or SSH)') - .action((opts: { browser?: boolean }) => { - const url = appLoginUrl(resolveAppBase()) - console.log('To install Ellipsis, open this URL and sign in:') - console.log(` ${url}`) - if (opts.browser !== false) { - openBrowser(url) - } - }) -} diff --git a/src/commands/login.ts b/src/commands/login.ts deleted file mode 100644 index b29a558..0000000 --- a/src/commands/login.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Command } from 'commander' -import { api } from '../lib/api' -import { - activeHostName, - clearActiveHostToken, - clearAllTokens, - resolveAppBase, -} from '../lib/config' -import { deviceLogin, openBrowser, persistToken } from '../lib/auth' -import { cliAuthUrl } from '../lib/urls' - -export function registerLogin(program: Command): void { - program - .command('login') - .description('Authenticate against the active host via the device-code flow') - .option('--no-browser', 'do not auto-open the verification URL (for headless or SSH)') - .action(async (opts: { browser?: boolean }) => { - try { - const { token } = await deviceLogin(api(), { - onPrompt: (start) => { - // Build the approval URL from the app base of the host the CLI is - // pointed at, NOT the server's verification_uri_complete — the - // backend defaults that to prod, so a beta / self-hosted login would - // otherwise be sent to the prod dashboard (where the code can't be - // approved). See cliAuthUrl / resolveAppBase. - const verificationUrl = cliAuthUrl(resolveAppBase(), start.user_code) - console.log('To authenticate, open this URL and approve the request:') - console.log(` ${verificationUrl}`) - console.log(`Verification code: ${start.user_code}`) - if (opts.browser !== false) { - openBrowser(verificationUrl) - } - console.log('Waiting for approval…') - }, - }) - persistToken(token) - console.log('✓ Logged in.') - } catch (err) { - console.error(`login failed: ${(err as Error).message}`) - process.exitCode = 1 - } - }) - - program - .command('logout') - .description("Remove the active host's stored token, or every host's with --all") - .option('--all', 'clear the stored token for every host, not just the active one') - .action((opts: { all?: boolean }) => { - // Clear only the on-disk token(s); the host entries (api/app base) stay so - // the next `agent login` targets the same instance. - if (opts.all) { - clearAllTokens() - } else { - clearActiveHostToken() - } - // A token supplied via ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives in - // the environment and keeps working — don't claim to have cleared what we - // can't. - const envNote = process.env.ELLIPSIS_API_TOKEN - ? ' ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.' - : '' - const scope = opts.all - ? 'all hosts' - : (activeHostName() ?? 'the active host') - console.log(`Removed stored credentials for ${scope}.${envNote}`) - }) -} diff --git a/src/commands/me.ts b/src/commands/me.ts deleted file mode 100644 index 0ee677c..0000000 --- a/src/commands/me.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Command } from 'commander' -import { api } from '../lib/api' -import { requireToken } from '../lib/config' -import { apiRoutes } from '../lib/help' -import { printJson, runAction } from '../lib/output' -import type { WhoAmI } from '../lib/types' - -// Renders the human-readable identity summary. Prefers the GitHub login over the -// bare numeric user id when the server resolved a gh_user, falling back to the id. -export function renderMe(me: WhoAmI): void { - console.log(`customer: ${me.customer_login} (${me.customer_id})`) - if (me.gh_user) console.log(`user: ${me.gh_user.login} (${me.user_id})`) - else if (me.user_id) console.log(`user: ${me.user_id}`) - if (me.api_key_id) console.log(`api key: ${me.api_key_id}`) - if (me.sandbox_id) console.log(`sandbox: ${me.sandbox_id}`) -} - -export function registerMe(program: Command): void { - apiRoutes( - program.command('me').description('Show the identity behind the current credential'), - 'GET /v1/identity', - ) - .option('--json', 'output raw JSON') - .action(async (opts: { json?: boolean }) => { - await runAction(async () => { - // Fail fast with the login hint when no credential exists anywhere; - // without this the request would go out unauthenticated and come back - // as a 401. - requireToken() - const me = await api().identity() - if (opts.json) { - printJson(me) - return - } - renderMe(me) - }) - }) -} diff --git a/src/commands/ping.ts b/src/commands/ping.ts deleted file mode 100644 index dc624c4..0000000 --- a/src/commands/ping.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Command } from 'commander' -import { api, APIError } from '../lib/api' -import { apiRoutes } from '../lib/help' - -export function registerPing(program: Command): void { - apiRoutes( - program - .command('ping') - .description('Check that the API is reachable and the credential is valid'), - 'GET /v1/identity', - ) - .action(async () => { - // There's no unauthenticated health route on the public API, so we probe - // the lightest authenticated endpoint (/me): a 200 proves the API is - // reachable AND the stored token is valid. - try { - const me = await api().identity() - console.log(`ok: ${me.customer_login} (${me.customer_id})`) - } catch (err) { - if (err instanceof APIError && err.status === 401) { - // Reachable, just not authenticated — point the user at login. - console.error('reachable, but not authenticated. Run `agent login` first.') - } else if (err instanceof APIError) { - console.error(`ping failed: ${err.status} ${err.message}`) - } else { - // Network/DNS/connection error: never got an HTTP response. - console.error(`cannot reach the API: ${(err as Error).message}`) - } - process.exitCode = 1 - } - }) -} diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts new file mode 100644 index 0000000..9e761be --- /dev/null +++ b/src/commands/uninstall.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import type { Command } from 'commander' +import { configDir } from '../lib/config' +import { installKind, startupFiles, stripInstallerLines } from '../lib/install' + +interface UninstallOptions { + purge?: boolean +} + +export function registerUninstall(program: Command): void { + program + .command('uninstall') + .description('Remove this CLI from the machine (add --purge to delete ~/.ellipsis too)') + .option('--purge', 'also delete the config dir, with every host and stored credential') + .action((opts: UninstallOptions) => { + try { + uninstall(opts.purge === true) + } catch (err) { + console.error(`uninstall failed: ${(err as Error).message}`) + process.exitCode = 1 + } + }) +} + +function uninstall(purge: boolean): void { + const execPath = realpathSync(process.execPath) + const kind = installKind(execPath) + if (kind === 'source') { + throw new Error('agent is running from source; uninstall only removes an installed binary') + } + if (kind === 'homebrew') { + throw new Error('this agent was installed with Homebrew. Run `brew uninstall agent` instead') + } + + // Startup files first, then the config dir, then the binary itself. Deleting + // a running executable is fine on macOS and Linux: the process keeps its + // open copy until it exits. + const cleaned: string[] = [] + for (const file of startupFiles(homedir())) { + if (!existsSync(file)) continue + const before = readFileSync(file, 'utf8') + const after = stripInstallerLines(before) + if (after !== before) { + writeFileSync(file, after) + cleaned.push(file) + } + } + + const config = configDir() + if (purge) rmSync(config, { recursive: true, force: true }) + + unlinkSync(execPath) + + console.log(`Removed ${execPath}`) + for (const file of cleaned) console.log(`Removed the PATH line from ${file}`) + if (cleaned.length > 0) console.log('Open a new shell to drop it from PATH.') + if (purge) { + console.log(`Removed ${config}`) + } else if (existsSync(config)) { + console.log(`Kept ${config} (hosts and credentials). Delete it with: rm -rf ${config}`) + } +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..4441bda --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,98 @@ +import { execFileSync } from 'node:child_process' +import { realpathSync } from 'node:fs' +import { Option, type Command } from 'commander' +import { VERSION } from '../lib/constants' +import { + compareVersions, + fetchLatestVersion, + installKind, + isMusl, + releaseTarget, + replaceBinary, +} from '../lib/install' +import { writeUpdateState } from '../lib/update-check' + +interface UpdateOptions { + to?: string + check?: boolean + quiet?: boolean +} + +export function registerUpdate(program: Command): void { + program + .command('update') + .description('Update this CLI to the latest release') + .option('--to ', 'install that release instead of the latest one (downgrades too)') + .option('--check', 'report whether a newer release exists, without installing it') + .addOption( + // Used by the daily background check: record the answer, print nothing. + new Option('--quiet', 'with --check: record the result and print nothing').hideHelp(), + ) + .action(async (opts: UpdateOptions) => { + try { + if (opts.check) { + await check(opts.quiet === true) + return + } + await update(opts.to) + } catch (err) { + console.error(`update failed: ${(err as Error).message}`) + process.exitCode = 1 + } + }) +} + +async function check(quiet: boolean): Promise { + const latest = await fetchLatestVersion() + writeUpdateState({ checkedAt: new Date().toISOString(), latest }) + if (quiet) return + const cmp = compareVersions(latest, VERSION) + if (cmp > 0) { + console.log(`agent ${latest} is available (you have ${VERSION}). Run \`agent update\`.`) + } else if (cmp === 0) { + console.log(`agent ${VERSION} is the latest release.`) + } else { + console.log(`agent ${VERSION} is newer than the latest release (${latest}).`) + } +} + +async function update(to: string | undefined): Promise { + const execPath = realpathSync(process.execPath) + const kind = installKind(execPath) + if (kind === 'source') { + throw new Error('agent is running from source; update only replaces an installed binary') + } + if (kind === 'homebrew') { + throw new Error( + 'this agent was installed with Homebrew. Run `brew uninstall agent`, then reinstall with install.sh', + ) + } + const target = releaseTarget(process.platform, process.arch, isMusl()) + if (!target) { + throw new Error(`no release build for ${process.platform}-${process.arch}`) + } + + let version: string + if (to) { + version = to.replace(/^v/, '') + } else { + version = await fetchLatestVersion() + writeUpdateState({ checkedAt: new Date().toISOString(), latest: version }) + const cmp = compareVersions(version, VERSION) + if (cmp === 0) { + console.log(`agent ${VERSION} is already the latest release.`) + return + } + if (cmp < 0) { + console.log( + `agent ${VERSION} is newer than the latest release (${version}). Pass --to ${version} to downgrade.`, + ) + return + } + } + + console.log(`Updating agent from ${VERSION} to ${version} (${target})...`) + await replaceBinary(execPath, version, target) + const reported = execFileSync(execPath, ['--version'], { stdio: 'pipe' }).toString().trim() + console.log(`Updated agent to ${reported} at ${execPath}`) +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 54fe1e5..ff80d4b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -175,7 +175,7 @@ export function updateHost( // Ensure there IS an active host, seeding one at the resolved base (env or // prod default) if the user logged in / enrolled before adding a host. Returns -// the active host's name. This is what makes a bare `agent login` work. +// the active host's name. This is what makes a bare `agent auth login` work. export function ensureActiveHost(): string { const cfg = loadConfig() if (cfg.activeHost && cfg.hosts[cfg.activeHost]) return cfg.activeHost @@ -255,7 +255,7 @@ export function requireToken(): string { const token = resolveToken() if (!token) { throw new Error( - 'Not logged in. Run `agent login` first, or set ELLIPSIS_API_TOKEN.', + 'Not logged in. Run `agent auth login` first, or set ELLIPSIS_API_TOKEN.', ) } return token diff --git a/src/lib/help.ts b/src/lib/help.ts index a76f321..b98881c 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -23,7 +23,8 @@ const TOP_LEVEL_GROUPS: ReadonlyArray<{ title: string; commands: readonly string { title: 'Platform', commands: ['variable', 'file'] }, { title: 'Integrations', commands: ['integration', 'github', 'slack', 'linear', 'sentry'] }, { title: 'Spend', commands: ['budget', 'usage', 'analytics'] }, - { title: 'Account', commands: ['install', 'login', 'logout', 'me', 'host', 'ping'] }, + { title: 'Account', commands: ['auth', 'host'] }, + { title: 'CLI', commands: ['update', 'uninstall'] }, { title: 'Help', commands: ['help'] }, ] diff --git a/src/lib/install.ts b/src/lib/install.ts new file mode 100644 index 0000000..ca6844c --- /dev/null +++ b/src/lib/install.ts @@ -0,0 +1,221 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { USER_AGENT } from './constants' + +// How the CLI is installed and kept current without a package manager. The +// release workflow publishes one tarball per target plus checksums.txt to +// GitHub Releases. install.sh (the first install) and `agent update` (every +// later one) both download from there and verify the SHA-256 before swapping +// the binary in. The pure helpers come first so test/install.test.ts can cover +// them without touching the network or the disk. + +export const RELEASES_BASE = 'https://github.com/ellipsis-dev/cli/releases' +export const BINARY_NAME = 'agent' + +// The comment install.sh appends to the PATH line it writes into a shell +// startup file. `agent uninstall` deletes exactly the lines carrying it, so +// the two must stay identical (test/install.test.ts checks). +export const PATH_MARKER = 'Ellipsis agent installer' + +// Every target release.yml builds (test/install.test.ts checks the workflow). +export const RELEASE_TARGETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-x64', + 'linux-arm64', + 'linux-x64-musl', + 'linux-arm64-musl', +] as const +export type ReleaseTarget = (typeof RELEASE_TARGETS)[number] + +// Map Node's platform and arch names onto a release target, or undefined when +// no build exists for this machine. +export function releaseTarget( + platform: string, + arch: string, + musl = false, +): ReleaseTarget | undefined { + const os = platform === 'darwin' || platform === 'linux' ? platform : undefined + const cpu = arch === 'x64' || arch === 'arm64' ? arch : undefined + if (!os || !cpu) return undefined + const name = `${os}-${cpu}${os === 'linux' && musl ? '-musl' : ''}` + return (RELEASE_TARGETS as readonly string[]).includes(name) ? (name as ReleaseTarget) : undefined +} + +export function tarballName(target: ReleaseTarget): string { + return `${BINARY_NAME}-${target}.tar.gz` +} + +// Asset URLs for one release, or for whatever GitHub currently calls latest. +// The latest redirect needs no API call, so CI never hits a rate limit. +export function releaseUrls( + version: string | undefined, + target: ReleaseTarget, +): { tarball: string; checksums: string } { + const base = version + ? `${RELEASES_BASE}/download/v${version}` + : `${RELEASES_BASE}/latest/download` + return { tarball: `${base}/${tarballName(target)}`, checksums: `${base}/checksums.txt` } +} + +// checksums.txt is `sha256sum` output: one ` ` line per asset. +export function parseChecksums(text: string): Map { + const out = new Map() + for (const line of text.split('\n')) { + const m = /^([0-9a-f]{64})\s+\*?(\S+)$/i.exec(line.trim()) + if (m) out.set(m[2], m[1].toLowerCase()) + } + return out +} + +// Numeric compare of x.y.z strings (a leading v is ignored): negative when a +// is older than b, zero when equal, positive when a is newer. +export function compareVersions(a: string, b: string): number { + const parse = (v: string): number[] => + v + .replace(/^v/, '') + .split('.') + .map((n) => Number.parseInt(n, 10) || 0) + const pa = parse(a) + const pb = parse(b) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0) + if (d !== 0) return d + } + return 0 +} + +// GitHub answers /releases/latest with a redirect to /releases/tag/v; +// the version is the tail of that URL. +export function versionFromReleaseUrl(url: string): string | undefined { + const m = /\/tag\/v?(\d+\.\d+\.\d+)\/?$/.exec(url) + return m?.[1] +} + +// Drop the lines install.sh added to a startup file, and nothing else. The +// installer wrote a blank line before its own, so that goes too: repeated +// install and uninstall cycles must not grow the file. +export function stripInstallerLines(content: string): string { + const lines = content.split('\n') + if (!lines.some((line) => line.includes(PATH_MARKER))) return content + const out: string[] = [] + for (const line of lines) { + if (line.includes(PATH_MARKER)) { + if (out.length > 0 && out[out.length - 1] === '') out.pop() + continue + } + out.push(line) + } + return out.join('\n') +} + +// Where the running executable came from decides what update and uninstall +// may do: only a binary the installer put down is theirs to replace or delete. +export type InstallKind = 'binary' | 'source' | 'homebrew' + +export function installKind(execPath: string): InstallKind { + const name = basename(execPath).replace(/\.exe$/i, '') + if (['node', 'bun', 'bun-profile', 'tsx'].includes(name)) return 'source' + if (execPath.includes('/Cellar/')) return 'homebrew' + return 'binary' +} + +// Every startup file install.sh may have written; uninstall scans them all. +export function startupFiles(home: string): string[] { + return [ + join(home, '.zshrc'), + join(home, '.bashrc'), + join(home, '.bash_profile'), + join(home, '.bash_login'), + join(home, '.profile'), + join(home, '.config', 'fish', 'config.fish'), + ] +} + +// ---- everything below touches the machine or the network ------------------ + +// musl (Alpine and friends) needs its own build; its dynamic loader is the tell. +export function isMusl(): boolean { + if (process.platform !== 'linux') return false + try { + return readdirSync('/lib').some((f) => /^ld-musl-.*\.so\.1$/.test(f)) + } catch { + return false + } +} + +export async function fetchLatestVersion(): Promise { + const res = await fetch(`${RELEASES_BASE}/latest`, { + method: 'HEAD', + redirect: 'manual', + headers: { 'user-agent': USER_AGENT }, + signal: AbortSignal.timeout(10_000), + }) + const location = res.headers.get('location') ?? res.url + const version = versionFromReleaseUrl(location) + if (!version) { + throw new Error( + `could not read the latest version from ${RELEASES_BASE}/latest (HTTP ${res.status})`, + ) + } + return version +} + +async function downloadTo(url: string, dest: string): Promise { + const res = await fetch(url, { + headers: { 'user-agent': USER_AGENT }, + signal: AbortSignal.timeout(180_000), + }) + if (!res.ok) throw new Error(`download failed: ${url} (HTTP ${res.status})`) + writeFileSync(dest, Buffer.from(await res.arrayBuffer())) +} + +export function sha256File(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +// Download release `version` for `target`, verify it, and swap it in over the +// running executable. The new file is staged in the same directory so the +// final rename is atomic: the old binary is never left half written. +export async function replaceBinary( + execPath: string, + version: string, + target: ReleaseTarget, +): Promise { + const urls = releaseUrls(version, target) + const name = tarballName(target) + const work = mkdtempSync(join(tmpdir(), `${BINARY_NAME}-update-`)) + const staged = join(dirname(execPath), `.${BINARY_NAME}.update.${process.pid}`) + try { + await downloadTo(urls.checksums, join(work, 'checksums.txt')) + await downloadTo(urls.tarball, join(work, name)) + const expected = parseChecksums(readFileSync(join(work, 'checksums.txt'), 'utf8')).get(name) + if (!expected) throw new Error(`checksums.txt for ${version} has no entry for ${name}`) + const actual = sha256File(join(work, name)) + if (actual !== expected) { + throw new Error(`checksum mismatch for ${name}: expected ${expected}, got ${actual}`) + } + execFileSync('tar', ['-xzf', join(work, name), '-C', work], { stdio: 'pipe' }) + const extracted = join(work, BINARY_NAME) + if (!existsSync(extracted)) throw new Error(`${name} does not contain ${BINARY_NAME}`) + copyFileSync(extracted, staged) + chmodSync(staged, 0o755) + renameSync(staged, execPath) + } finally { + rmSync(work, { recursive: true, force: true }) + rmSync(staged, { force: true }) + } +} diff --git a/src/lib/output.ts b/src/lib/output.ts index baa7df7..ad076a0 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -68,12 +68,12 @@ export function usd(amount: number): string { // malformed) regardless of which endpoint hit it, so tell the user how to get // a new one instead of echoing the raw HTTP failure. The remedy depends on // where the token came from: an env token outranks the config file in the -// precedence chain, so `agent login` alone can't replace it. +// precedence chain, so `agent auth login` alone can't replace it. export function friendlyErrorMessage(err: unknown): string { if (err instanceof APIError && err.status === 401) { return envToken() - ? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent login`.' - : 'Your login is invalid or has expired. Run `agent login` to re-authenticate.' + ? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent auth login`.' + : 'Your login is invalid or has expired. Run `agent auth login` to re-authenticate.' } // A 429 message is written for a human to act on (which limit was hit, how // to get it raised), so print it alone — the status prefix buries the remedy. @@ -111,7 +111,7 @@ function upgradeHint(): string { return ( `It's possible we shipped a breaking change to our API. ` + `You are currently on version ${VERSION}. ` + - 'Check if there is a newer CLI version available by running: brew upgrade ellipsis-dev/cli/agent' + 'Check for a newer CLI by running: agent update' ) } diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..3135475 --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,89 @@ +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { configDir } from './config' +import { VERSION } from './constants' +import { compareVersions, installKind } from './install' + +// Without a package manager nothing tells a user their CLI is stale, so the +// binary does it itself. Once a day it asks GitHub for the latest release in +// a detached child (`agent update --check --quiet`) and remembers the answer +// in the config dir. The next run prints one line on stderr when that answer +// is newer than itself. Nothing here may slow down or fail the command in +// progress: the network call never happens in this process, and every error +// is dropped. + +export interface UpdateState { + checkedAt: string + latest?: string +} + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 + +function updateStateFile(): string { + return join(configDir(), 'update-check.json') +} + +export function readUpdateState(): UpdateState | undefined { + try { + const file = updateStateFile() + if (!existsSync(file)) return undefined + const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial + if (typeof raw.checkedAt !== 'string') return undefined + return { + checkedAt: raw.checkedAt, + latest: typeof raw.latest === 'string' ? raw.latest : undefined, + } + } catch { + return undefined + } +} + +export function writeUpdateState(state: UpdateState): void { + mkdirSync(configDir(), { recursive: true }) + writeFileSync(updateStateFile(), JSON.stringify(state)) +} + +// Is it time to ask GitHub again? +export function checkIsDue(state: UpdateState | undefined, now: Date): boolean { + if (!state) return true + const last = Date.parse(state.checkedAt) + return Number.isNaN(last) || now.getTime() - last >= CHECK_INTERVAL_MS +} + +// The one-line nudge, or undefined when the remembered latest is not newer. +export function updateNudge(state: UpdateState | undefined, current: string): string | undefined { + if (!state?.latest || compareVersions(state.latest, current) <= 0) return undefined + return `A newer agent is available: ${state.latest} (you have ${current}). Run \`agent update\`.` +} + +// Invocations that must not start a check: they are the check, they are about +// to delete the binary, or they never reach a command at all. +const QUIET_FIRST_ARGS = new Set(['update', 'uninstall', 'help', '--help', '-h', '--version', '-V']) + +export function maybeNudgeUpdate(argv: readonly string[]): void { + try { + if (process.env.ELLIPSIS_NO_UPDATE_CHECK || process.env.CI) return + if (!process.stderr.isTTY) return + if (installKind(process.execPath) !== 'binary') return + const first = argv[2] + if (first === undefined || QUIET_FIRST_ARGS.has(first)) return + + const state = readUpdateState() + const nudge = updateNudge(state, VERSION) + if (nudge) console.error(nudge) + if (!checkIsDue(state, new Date())) return + + // Throttle before spawning, so an offline machine asks once a day rather + // than once a command. + writeUpdateState({ checkedAt: new Date().toISOString(), latest: state?.latest }) + const child = spawn(process.execPath, ['update', '--check', '--quiet'], { + detached: true, + stdio: 'ignore', + env: { ...process.env, ELLIPSIS_NO_UPDATE_CHECK: '1' }, + }) + child.unref() + } catch { + // The nudge is never worth breaking the command over. + } +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 3959d40..00255fa 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -14,7 +14,7 @@ export function automationUrl(appBase: string, accountLogin: string, automationI return `${appBase}/${encodeURIComponent(accountLogin)}/automations/${encodeURIComponent(automationId)}` } -// The device-code approval page for `agent login`. `userCode` is the user_code +// The device-code approval page for `agent auth login`. `userCode` is the user_code // minted by POST /cli-auth/start. Built client-side from the active host's // app base (not the server's verification_uri_complete) so the host always // matches the instance the CLI is pointed at: the backend fills its own copy @@ -24,10 +24,3 @@ export function automationUrl(appBase: string, accountLogin: string, automationI export function cliAuthUrl(appBase: string, userCode: string): string { return `${appBase}/cli-auth?code=${encodeURIComponent(userCode)}` } - -// The dashboard sign-in page, where a new customer installs the Ellipsis -// GitHub App. Built from the active host's app base for the same reason as -// cliAuthUrl: a beta or self-hosted CLI must not send its user to prod. -export function appLoginUrl(appBase: string): string { - return `${appBase}/login` -} diff --git a/test/auth.test.ts b/test/auth.test.ts index 4d36c2b..eee43bb 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,88 +1,56 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { deviceLogin } from '../src/lib/auth' -import type { Ellipsis } from '@ellipsis-dev/sdk' -import type { CliAuthPoll, CliAuthStart } from '../src/lib/types' - -const START: CliAuthStart = { - device_code: 'dev_abc', - user_code: 'WXYZ-1234', - verification_uri: 'https://app.test/cli-auth', - verification_uri_complete: 'https://app.test/cli-auth?code=WXYZ-1234', - interval: 1, // 1s between polls (Math.max(1, …) floor) - expires_in: 10, -} - -// Minimal fake satisfying the two methods deviceLogin uses. -function fakeApi(pollResults: CliAuthPoll[]): { - api: Ellipsis - poll: ReturnType -} { - const poll = vi.fn() - for (const r of pollResults) poll.mockResolvedValueOnce(r) - const api = { - auth: { cli: { start: vi.fn(async () => START), poll } }, - } as unknown as Ellipsis - return { api, poll } +import { renderIdentity, tokenSource } from '../src/commands/auth' +import type { WhoAmI } from '../src/lib/types' + +function base(overrides: Partial = {}): WhoAmI { + return { + customer_id: 'cust_1', + customer_login: 'ellipsis-dev', + user_id: null, + gh_user: null, + api_key_id: null, + sandbox_id: null, + ...overrides, + } } -describe('deviceLogin', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it('prompts once, then returns the token after polling through pending', async () => { - const { api, poll } = fakeApi([ - { status: 'pending' }, - { status: 'approved', access_token: 'ellipsis_user_tok' }, - ]) - const onPrompt = vi.fn() - const onPending = vi.fn() - - const promise = deviceLogin(api, { onPrompt, onPending }) - await vi.advanceTimersByTimeAsync(1000) // first sleep -> pending - await vi.advanceTimersByTimeAsync(1000) // second sleep -> approved - - await expect(promise).resolves.toEqual({ token: 'ellipsis_user_tok' }) - expect(onPrompt).toHaveBeenCalledTimes(1) - expect(onPrompt).toHaveBeenCalledWith(START) - expect(onPending).toHaveBeenCalledTimes(1) - expect(poll).toHaveBeenCalledTimes(2) - expect(poll).toHaveBeenCalledWith({ device_code: 'dev_abc' }) +describe('tokenSource', () => { + it('follows resolveToken precedence: the environment beats the config file', () => { + expect(tokenSource(true, true)).toBe('env') + expect(tokenSource(true, false)).toBe('env') + expect(tokenSource(false, true)).toBe('config') + expect(tokenSource(false, false)).toBe('none') }) +}) - it('rejects when the request is denied', async () => { - const { api } = fakeApi([{ status: 'denied' }]) - const promise = deviceLogin(api, { onPrompt: vi.fn() }) - const assertion = expect(promise).rejects.toThrow(/denied/) - await vi.advanceTimersByTimeAsync(1000) - await assertion +describe('renderIdentity', () => { + let lines: string[] + beforeEach(() => { + lines = [] + vi.spyOn(console, 'log').mockImplementation((msg?: unknown) => { + lines.push(String(msg)) + }) }) - - it('rejects when the request expires server-side', async () => { - const { api } = fakeApi([{ status: 'expired' }]) - const promise = deviceLogin(api, { onPrompt: vi.fn() }) - const assertion = expect(promise).rejects.toThrow(/expired/) - await vi.advanceTimersByTimeAsync(1000) - await assertion + afterEach(() => vi.restoreAllMocks()) + + it('shows the gh_user login alongside the id when resolved', () => { + renderIdentity( + base({ + user_id: '24214708', + gh_user: { id: 24214708, login: 'hbrooks', name: 'Hunter' }, + }), + ) + expect(lines).toContain('user: hbrooks (24214708)') }) - it('errors if approved without a token', async () => { - const { api } = fakeApi([{ status: 'approved' }]) - const promise = deviceLogin(api, { onPrompt: vi.fn() }) - const assertion = expect(promise).rejects.toThrow(/no token/) - await vi.advanceTimersByTimeAsync(1000) - await assertion + it('falls back to the bare user id when gh_user is null', () => { + renderIdentity(base({ user_id: '24214708', gh_user: null })) + expect(lines).toContain('user: 24214708') }) - it('times out once the deadline passes with no approval', async () => { - // Always pending; expires_in is 10s. - const poll = vi.fn().mockResolvedValue({ status: 'pending' } satisfies CliAuthPoll) - const api = { - auth: { cli: { start: vi.fn(async () => START), poll } }, - } as unknown as Ellipsis - - const promise = deviceLogin(api, { onPrompt: vi.fn() }) - const assertion = expect(promise).rejects.toThrow(/Timed out/) - await vi.advanceTimersByTimeAsync(12_000) // past expires_in - await assertion + it('omits the user line entirely for api-key principals', () => { + renderIdentity(base({ api_key_id: 'eak_123' })) + expect(lines.some((l) => l.startsWith('user:'))).toBe(false) + expect(lines).toContain('api key: eak_123') }) }) diff --git a/test/install.test.ts b/test/install.test.ts new file mode 100644 index 0000000..dac5679 --- /dev/null +++ b/test/install.test.ts @@ -0,0 +1,180 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + compareVersions, + installKind, + parseChecksums, + PATH_MARKER, + RELEASE_TARGETS, + releaseTarget, + releaseUrls, + stripInstallerLines, + versionFromReleaseUrl, +} from '../src/lib/install' +import { checkIsDue, updateNudge } from '../src/lib/update-check' + +const repoFile = (rel: string): string => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8') + +describe('releaseTarget', () => { + it('maps node platform and arch names onto the published targets', () => { + expect(releaseTarget('darwin', 'arm64')).toBe('darwin-arm64') + expect(releaseTarget('darwin', 'x64')).toBe('darwin-x64') + expect(releaseTarget('linux', 'x64')).toBe('linux-x64') + expect(releaseTarget('linux', 'arm64')).toBe('linux-arm64') + }) + + it('picks the musl build only on linux', () => { + expect(releaseTarget('linux', 'x64', true)).toBe('linux-x64-musl') + expect(releaseTarget('linux', 'arm64', true)).toBe('linux-arm64-musl') + expect(releaseTarget('darwin', 'arm64', true)).toBe('darwin-arm64') + }) + + it('returns undefined where nothing is built', () => { + expect(releaseTarget('win32', 'x64')).toBeUndefined() + expect(releaseTarget('linux', 'ia32')).toBeUndefined() + }) +}) + +describe('releaseUrls', () => { + it('pins a version under /download/v', () => { + expect(releaseUrls('2.30.0', 'linux-x64')).toEqual({ + tarball: 'https://github.com/ellipsis-dev/cli/releases/download/v2.30.0/agent-linux-x64.tar.gz', + checksums: 'https://github.com/ellipsis-dev/cli/releases/download/v2.30.0/checksums.txt', + }) + }) + + it('uses the latest redirect when no version is given, so no API call is needed', () => { + expect(releaseUrls(undefined, 'darwin-arm64').tarball).toBe( + 'https://github.com/ellipsis-dev/cli/releases/latest/download/agent-darwin-arm64.tar.gz', + ) + }) +}) + +describe('parseChecksums', () => { + it('reads sha256sum output into a name to digest map', () => { + const a = 'a'.repeat(64) + const b = 'B'.repeat(64) + const text = `${a} agent-linux-x64.tar.gz\n${b} *agent-darwin-arm64.tar.gz\n\nnot a checksum line\n` + const sums = parseChecksums(text) + expect(sums.get('agent-linux-x64.tar.gz')).toBe(a) + expect(sums.get('agent-darwin-arm64.tar.gz')).toBe('b'.repeat(64)) + expect(sums.size).toBe(2) + }) +}) + +describe('compareVersions', () => { + it('orders numerically per component', () => { + expect(compareVersions('2.31.0', '2.30.0')).toBeGreaterThan(0) + expect(compareVersions('2.30.10', '2.30.9')).toBeGreaterThan(0) + expect(compareVersions('3.0.0', '2.99.99')).toBeGreaterThan(0) + expect(compareVersions('2.29.0', '2.30.0')).toBeLessThan(0) + }) + + it('treats a leading v and a missing component as nothing', () => { + expect(compareVersions('v2.30.0', '2.30.0')).toBe(0) + expect(compareVersions('2.30', '2.30.0')).toBe(0) + }) +}) + +describe('versionFromReleaseUrl', () => { + it('reads the tag off the latest redirect', () => { + expect(versionFromReleaseUrl('https://github.com/ellipsis-dev/cli/releases/tag/v2.30.0')).toBe( + '2.30.0', + ) + expect(versionFromReleaseUrl('https://github.com/ellipsis-dev/cli/releases/tag/2.30.0/')).toBe( + '2.30.0', + ) + }) + + it('returns undefined when the URL is not a tag page', () => { + expect(versionFromReleaseUrl('https://github.com/ellipsis-dev/cli/releases')).toBeUndefined() + expect(versionFromReleaseUrl('')).toBeUndefined() + }) +}) + +describe('stripInstallerLines', () => { + const line = `export PATH="$HOME/.local/bin:$PATH" # ${PATH_MARKER}` + + it('removes the installer line and the blank line written before it', () => { + const before = `alias ll='ls -l'\n\n${line}\n` + expect(stripInstallerLines(before)).toBe(`alias ll='ls -l'\n`) + }) + + it('keeps everything else, including blank lines that are not ours', () => { + const before = `a\n\n\n${line}\nb\n\nc\n` + expect(stripInstallerLines(before)).toBe(`a\n\nb\n\nc\n`) + }) + + it('returns the content untouched when there is nothing to remove', () => { + const before = `export PATH="$HOME/bin:$PATH"\n` + expect(stripInstallerLines(before)).toBe(before) + }) + + it('handles a file whose last line is ours and has no trailing newline', () => { + expect(stripInstallerLines(`a\n${line}`)).toBe('a') + }) +}) + +describe('installKind', () => { + it('recognises a source run by the runtime name', () => { + expect(installKind('/usr/local/bin/node')).toBe('source') + expect(installKind('/Users/me/.bun/bin/bun')).toBe('source') + }) + + it('recognises a Homebrew cellar path', () => { + expect(installKind('/opt/homebrew/Cellar/agent/2.30.0/bin/agent')).toBe('homebrew') + expect(installKind('/home/linuxbrew/.linuxbrew/Cellar/agent/2.30.0/bin/agent')).toBe('homebrew') + }) + + it('treats anything else as an installed binary', () => { + expect(installKind('/Users/me/.local/bin/agent')).toBe('binary') + expect(installKind('/usr/local/bin/agent')).toBe('binary') + }) +}) + +describe('checkIsDue', () => { + const now = new Date('2026-09-25T12:00:00Z') + + it('is due with no record, a stale record, or a broken one', () => { + expect(checkIsDue(undefined, now)).toBe(true) + expect(checkIsDue({ checkedAt: '2026-09-24T11:00:00Z' }, now)).toBe(true) + expect(checkIsDue({ checkedAt: 'garbage' }, now)).toBe(true) + }) + + it('is not due within a day of the last check', () => { + expect(checkIsDue({ checkedAt: '2026-09-25T01:00:00Z' }, now)).toBe(false) + }) +}) + +describe('updateNudge', () => { + it('names both versions when the remembered latest is newer', () => { + const msg = updateNudge({ checkedAt: 'x', latest: '2.31.0' }, '2.30.0') + expect(msg).toContain('2.31.0') + expect(msg).toContain('2.30.0') + expect(msg).toContain('agent update') + }) + + it('stays quiet when there is nothing newer', () => { + expect(updateNudge(undefined, '2.30.0')).toBeUndefined() + expect(updateNudge({ checkedAt: 'x' }, '2.30.0')).toBeUndefined() + expect(updateNudge({ checkedAt: 'x', latest: '2.30.0' }, '2.30.0')).toBeUndefined() + expect(updateNudge({ checkedAt: 'x', latest: '2.29.0' }, '2.30.0')).toBeUndefined() + }) +}) + +// The shell installer and the release workflow are not TypeScript, so the +// constants they share with the binary are checked here instead. +describe('install.sh and release.yml agree with the binary', () => { + it('install.sh writes the same PATH marker uninstall looks for', () => { + expect(repoFile('install.sh')).toContain(`PATH_MARKER="${PATH_MARKER}"`) + }) + + it('release.yml builds exactly the targets the binary knows about', () => { + const workflow = repoFile('.github/workflows/release.yml') + const loops = [...workflow.matchAll(/for t in ([^;]+); do/g)].map((m) => + m[1].split(/\s+/).filter(Boolean).sort(), + ) + expect(loops.length).toBeGreaterThan(0) + for (const targets of loops) expect(targets).toEqual([...RELEASE_TARGETS].sort()) + }) +}) diff --git a/test/me.test.ts b/test/me.test.ts deleted file mode 100644 index 639087a..0000000 --- a/test/me.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderMe } from '../src/commands/me' -import type { WhoAmI } from '../src/lib/types' - -function base(overrides: Partial = {}): WhoAmI { - return { - customer_id: 'cust_1', - customer_login: 'ellipsis-dev', - user_id: null, - gh_user: null, - api_key_id: null, - sandbox_id: null, - ...overrides, - } -} - -describe('renderMe', () => { - let lines: string[] - beforeEach(() => { - lines = [] - vi.spyOn(console, 'log').mockImplementation((msg?: unknown) => { - lines.push(String(msg)) - }) - }) - afterEach(() => vi.restoreAllMocks()) - - it('shows the gh_user login alongside the id when resolved', () => { - renderMe( - base({ - user_id: '24214708', - gh_user: { id: 24214708, login: 'hbrooks', name: 'Hunter' }, - }), - ) - expect(lines).toContain('user: hbrooks (24214708)') - }) - - it('falls back to the bare user id when gh_user is null', () => { - renderMe(base({ user_id: '24214708', gh_user: null })) - expect(lines).toContain('user: 24214708') - }) - - it('omits the user line entirely for api-key principals', () => { - renderMe(base({ api_key_id: 'eak_123' })) - expect(lines.some((l) => l.startsWith('user:'))).toBe(false) - expect(lines).toContain('api key: eak_123') - }) -}) diff --git a/test/output.test.ts b/test/output.test.ts index 8e700b2..a5c9e85 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -63,7 +63,7 @@ describe('friendlyErrorMessage', () => { it('maps a 401 to a re-login hint instead of the raw HTTP failure', () => { const err = apiError(401, 'Unauthorized', 'req_1') expect(friendlyErrorMessage(err)).toBe( - 'Your login is invalid or has expired. Run `agent login` to re-authenticate.', + 'Your login is invalid or has expired. Run `agent auth login` to re-authenticate.', ) }) @@ -101,14 +101,14 @@ describe('friendlyErrorMessage', () => { expect(msg).toContain(`${status} nope`) expect(msg).toContain("It's possible we shipped a breaking change to our API.") expect(msg).toContain('You are currently on version') - expect(msg).toContain('brew upgrade ellipsis-dev/cli/agent') + expect(msg).toContain('agent update') } }) it('suppresses the upgrade hint where updating is the wrong remedy', () => { for (const status of [402, 403, 404, 408, 409, 413, 502, 503, 504]) { const msg = friendlyErrorMessage(apiError(status, 'nope')) - expect(msg).not.toContain('brew upgrade') + expect(msg).not.toContain('agent update') } }) diff --git a/test/urls.test.ts b/test/urls.test.ts index a7ef2ae..1539006 100644 --- a/test/urls.test.ts +++ b/test/urls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { appLoginUrl, automationUrl, cliAuthUrl, sessionUrl } from '../src/lib/urls' +import { automationUrl, cliAuthUrl, sessionUrl } from '../src/lib/urls' describe('sessionUrl', () => { it('builds the account page link with the session query param', () => { @@ -36,15 +36,3 @@ describe('cliAuthUrl', () => { ) }) }) - -describe('appLoginUrl', () => { - it('builds the dashboard sign-in page url', () => { - expect(appLoginUrl('https://app.ellipsis.dev')).toBe('https://app.ellipsis.dev/login') - }) - - it('tracks the app base host, so a beta base yields a beta sign-in url', () => { - expect(appLoginUrl('https://beta-app.ellipsis.dev')).toBe( - 'https://beta-app.ellipsis.dev/login', - ) - }) -})