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', - ) - }) -})