diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b43900 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# Keeps the GitHub Actions in .github/workflows at their latest release. +# The actions are pinned by commit SHA with the release tag in a trailing +# comment, and Dependabot updates both. The golangci-lint and GoReleaser +# versions are action inputs, which Dependabot does not update. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b88eafa..649e4dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,14 +20,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' + go-version-file: go.mod cache: true - name: Run tests @@ -46,7 +46,7 @@ jobs: mv bin/educatesenv bin/${{ steps.binary.outputs.name }} - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.binary.outputs.name }} path: bin/${{ steps.binary.outputs.name }} diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 6a4c871..4eabcd6 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -5,22 +5,26 @@ on: branches: - develop - main + pull_request: + branches: + - develop + - main jobs: golangci: name: lint runs-on: ubuntu-latest steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: '0' - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' + go-version-file: go.mod cache: true - - uses: actions/checkout@v4 - with: - fetch-depth: '0' - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: latest + version: v2.15.0 args: -v \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f2c4b2..73dcedc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,22 @@ on: description: 'Tag or version to build (e.g., v1.2.3). If not set, uses latest commit.' required: false default: '' + skip-signing: + description: 'Release without signing and notarizing the macOS binaries, for when the Apple membership or certificate is unavailable.' + type: boolean + required: false + default: false jobs: release: name: Create Release runs-on: ubuntu-latest + env: + # false for a manual run with skip-signing, or for any run while the + # MACOS_SIGNING repository variable is false; true otherwise. The secrets + # check below and .goreleaser.yml both read it to decide whether to sign + # and notarize the macOS binaries. + MACOS_SIGNING: ${{ ((github.event_name == 'workflow_dispatch' && inputs.skip-signing) || vars.MACOS_SIGNING == 'false') && 'false' || 'true' }} permissions: contents: write # packages: write @@ -22,22 +33,53 @@ jobs: # id-token: write steps: + # Every release signs and notarizes the macOS binaries unless MACOS_SIGNING + # is false. Without this check, an empty notary secret would make + # GoReleaser sign the binaries, skip notarization and still succeed. + - name: Report a release without macOS signing + if: env.MACOS_SIGNING == 'false' + run: echo "::warning::This release does not sign or notarize the macOS binaries, because the skip-signing input is set or the MACOS_SIGNING repository variable is false." + + - name: Check the macOS signing and notarization secrets + if: env.MACOS_SIGNING != 'false' + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + missing="" + for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_KEY MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do + if [ -z "${!name}" ]; then + missing="$missing $name" + fi + done + if [ -n "$missing" ]; then + echo "::error::These secrets are empty or missing:$missing. Set them, or run the workflow manually with skip-signing to release unsigned." + exit 1 + fi + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.release_tag != '' && github.event.inputs.release_tag || github.ref_type == 'tag' && github.ref_name || github.sha }} + # GoReleaser's changelog needs the full history to find the previous + # tag. + fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' + go-version-file: go.mod cache: true - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: - # 'latest', 'nightly', or a semver - version: "~> v2" + version: v2.19.0 + # .goreleaser.yml signs and notarizes unless MACOS_SIGNING, set for the + # job above, is false. args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml index ec0c2f0..155f948 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,6 @@ version: "2" run: - go: '1.24' timeout: 5m modules-download-mode: readonly relative-path-mode: gomod diff --git a/.goreleaser.yml b/.goreleaser.yml index 8bc968a..cd46c90 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,7 +5,7 @@ version: 2 builds: - main: ./cmd/educatesenv id: educatesenv - binary: educatesenv-{{ .Os }}-{{ .Arch }} + binary: educatesenv env: - CGO_ENABLED=0 goos: @@ -22,6 +22,9 @@ builds: archives: - formats: - binary + # Release assets are named educatesenv--, as the download + # instructions in the release notes header expect. + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" checksum: name_template: 'checksums.txt' @@ -45,6 +48,12 @@ release: # We wait so that we don't make the release public until we have notarize email from Apple. draft: true + # Delete a draft release with the same name, the tag, before creating the + # release. A run that fails while uploading leaves a draft behind, and + # GitHub's lookup by tag never finds drafts, so without this a rerun would + # leave two drafts for the same tag. + replace_existing_draft: true + # What to do with the release notes in case there the release already exists. # # Valid options are: @@ -103,10 +112,18 @@ changelog: notarize: macos: - # Whether this configuration is enabled or not. + # + # MACOS_SIGNING switches signing and notarization, the same way for + # goreleaser release and goreleaser build. A release signs unless + # MACOS_SIGNING is false, so it fails when MACOS_SIGN_P12 is empty or + # missing, as well as when signing fails. A snapshot signs only when + # MACOS_SIGNING is true, so that snapshots build without the + # certificate. The release workflow sets MACOS_SIGNING from its + # skip-signing input. # # Default: false. # Templates: allowed. - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + enabled: '{{ if .IsSnapshot }}{{ eq (tolower (trim (envOrDefault "MACOS_SIGNING" "false"))) "true" }}{{ else }}{{ ne (tolower (trim (envOrDefault "MACOS_SIGNING" "true"))) "false" }}{{ end }}' # Before notarizing, we need to sign the binary. # This block defines the configuration for doing so. diff --git a/README.md b/README.md index 6cde62f..ee2ce16 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,551 @@ # educatesenv -A version manager for the `educates` binary, inspired by [tfenv](https://github.com/tfutils/tfenv). Easily install, switch, and manage multiple versions of the educates training platform binary. +A version manager for the `educates` CLI of the +[Educates training platform](https://github.com/educates/educates-training-platform), +inspired by [tfenv](https://github.com/tfutils/tfenv). It installs +`educates` binaries from the platform's GitHub releases, keeps several +versions side by side, and switches between them. -## Features +educatesenv keeps everything under `~/.educatesenv`. Each installed +version is a binary named `educates-` in the bin directory, and +an `educates` symlink in the same directory points at the active one: -- Version management for educates binary -- Cross-platform support -- Development mode for local testing -- Automated releases with goreleaser +``` +~/.educatesenv/ +├── config.yaml +└── bin/ + ├── educates -> educates-3.8.0 + ├── educates-3.7.3 + └── educates-3.8.0 +``` + +With the bin directory on your `PATH`, `educates` runs whichever version +you selected last. + +educatesenv runs on macOS and Linux, on amd64 and arm64. ## Installation -### Using go install +### From a release -```bash -go install github.com/educates/educatesenv/cmd/educatesenv@latest +Each release publishes one binary per platform, named +`educatesenv--`, and a `checksums.txt` with their SHA-256 +checksums. Download the binary for your platform into the bin directory, +for example on macOS on Apple silicon: + +```sh +mkdir -p ~/.educatesenv/bin +cd ~/.educatesenv/bin +curl -fLO https://github.com/educates/educatesenv/releases/latest/download/educatesenv-darwin-arm64 +curl -fLO https://github.com/educates/educatesenv/releases/latest/download/checksums.txt +shasum -a 256 --ignore-missing -c checksums.txt +mv educatesenv-darwin-arm64 educatesenv +chmod +x educatesenv +rm checksums.txt ``` -### From releases +The other binaries are `educatesenv-darwin-amd64`, +`educatesenv-linux-amd64` and `educatesenv-linux-arm64`. On Linux, +`sha256sum --ignore-missing -c checksums.txt` checks the download. -Download the latest release from the [releases page](https://github.com/educates/educatesenv/releases). +Any directory on your `PATH` works. `~/.educatesenv/bin` has the +advantage that `educatesenv` and `educates` share one `PATH` entry. -## Usage +#### macOS and the quarantine attribute -The `educatesenv` command provides several subcommands: +The macOS binaries are signed and notarized, but a release can ship them +unsigned when signing is unavailable. macOS refuses to run an unsigned +binary that a browser downloaded, because the browser marks the file with +the quarantine attribute. Clear the attribute to run it: -```bash -educatesenv version # Display version information -educatesenv install # Install a specific version -educatesenv use # Switch to a specific version +```sh +xattr -d com.apple.quarantine ~/.educatesenv/bin/educatesenv ``` -## Development +Downloads made with `curl`, as above, are not quarantined and need no +such step, and neither do the `educates` binaries that educatesenv +downloads. -### Prerequisites +### With go install -- Go 1.21 or later -- Make -- [golangci-lint](https://golangci-lint.run/) for linting -- [goreleaser](https://goreleaser.com/) for releases +With Go 1.26 or later: -### Building and Testing +```sh +go install github.com/educates/educatesenv/cmd/educatesenv@latest +``` -```bash -# Build the binary -make build +This puts `educatesenv` in `$(go env GOPATH)/bin`, and +`educatesenv version` reports the module version you installed. -# Run tests -make test +### Setting up + +Run `init` once. It creates `~/.educatesenv/config.yaml`, readable only +by you, and the bin directory, and prints how to add the bin directory to +your `PATH` in your shell: + +```sh +~/.educatesenv/bin/educatesenv init +``` -# Run linter -make lint +Run it as plain `educatesenv init` when `educatesenv` is on your `PATH` +already, as it is after `go install`. -# Local install -make install +The instructions follow `$SHELL`: zsh users are told to edit `~/.zshrc`, +bash users `~/.bashrc`, and fish users get a `fish_user_paths` command. +For zsh, they read: -# Test release build -make release-test +```sh +echo 'export PATH="/Users/you/.educatesenv/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc ``` -### Code Quality +`init` never replaces a config file that exists. With `--download`, it +also installs the latest stable release of `educates` and makes it +active, and `--overwrite` makes it reinstall that release when it is +already installed: -We use golangci-lint for strict code quality enforcement. The configuration is in `.golangci.yml` and includes: +```sh +educatesenv init --download +``` -- Advanced linters enabled -- Strict formatting rules -- Comprehensive static analysis +## Usage -Run the linter: -```bash -make lint +``` +educatesenv init [--download] [--overwrite] +educatesenv list-remote [--recent N | --all] [--pre-releases] [--by-date] +educatesenv install [--use] [--overwrite] +educatesenv use +educatesenv list +educatesenv uninstall ... [--force] +educatesenv config init +educatesenv config view +educatesenv version +educatesenv completion +``` + +Every command takes `--help`. Commands that take no arguments fail when +given one, so a mistyped flag never does something else silently. + +### Listing available versions + +`list-remote` shows the 10 newest stable releases, highest version first, +with the date each was published, in UTC: + +```console +$ educatesenv list-remote +VERSION RELEASED +3.8.0 2026-08-26 +3.7.3 2026-07-17 +3.7.2 2026-06-30 +3.7.1 2026-04-12 +3.7.0 2026-03-30 +3.6.1 2026-03-19 +3.6.0 2026-03-04 +3.5.1 2025-12-12 +3.5.0 2025-12-01 +3.4.0 2025-09-29 +Showing 10 of 66 stable releases. Use --all to see all, --pre-releases to include pre-releases. ``` -### GitHub Actions +Two independent choices shape the list. Which releases: stable ones by +default, or stable ones and pre-releases with `--pre-releases`. How +many: 10 by default, `--recent N` (or `-n N`) for the N newest, or +`--all` for every one. `--all` and `--recent` conflict, and `--recent` +takes 1 or more. `--by-date` orders the list by publish date instead of +by version, and combines with every other flag: -The project uses GitHub Actions for automated workflows: +```sh +# The 20 newest stable releases +educatesenv list-remote --recent 20 -#### Build and Test Pipeline (`build.yml`) -- Triggered on pull requests and pushes to main/develop branches -- Matrix builds: - - Ubuntu and macOS - - amd64 and arm64 architectures (arm64 currently only on macOS) -- Steps: - - Go 1.24 setup with dependency caching - - Unit tests execution - - Platform-specific binary builds - - Binary artifacts upload +# Every stable release +educatesenv list-remote --all -#### Linting Pipeline (`golangci-lint.yaml`) -- Triggered on pushes to main/develop branches -- Runs golangci-lint v2.1.5 -- Performs comprehensive code quality checks -- Runs on Ubuntu latest +# The 10 highest versions, pre-releases included +educatesenv list-remote --pre-releases -#### Release Pipeline (`release.yml`) -- Triggered on version tags (v*) or manual dispatch -- Uses goreleaser for automated releases -- Steps: - - Go 1.24 setup - - Creates GitHub releases - - Builds for all supported platforms - - Generates release artifacts +# Every release +educatesenv list-remote --pre-releases --all -The workflow configurations are in `.github/workflows/`: -- `build.yml` - Build and test automation -- `golangci-lint.yaml` - Code quality checks -- `release.yml` - Release automation +# The 5 most recently published releases, pre-releases included +educatesenv list-remote --pre-releases --by-date -n 5 +``` -### Releases +The table goes to stdout. The line that says how many releases are shown +out of how many goes to stderr, so piping `list-remote` into another tool +passes only the table. When the list holds every release that matches, +there is no such line. -Releases are automated using goreleaser. The configuration is in `.goreleaser.yml` and handles: +educatesenv reads every release of the repository. A release counts as a +pre-release when its tag has a semver pre-release part, such as `-rc.1` +or `-alpha.13`, or when GitHub flags it as one. Draft releases are never +listed, and neither are tags that are not full semver versions without a +`v` prefix, the form Educates tags take. -- Cross-platform builds -- Checksums and signatures -- GitHub release automation -- Version information injection +The `--recents` flag is deprecated. It still parses, prints a +deprecation warning on stderr and changes nothing, since the 10 newest +releases are the default. A later release removes it. -### Project Structure +### Installing a version +```sh +educatesenv install 3.8.0 +educatesenv install latest --use ``` -. -├── cmd/ # Command line interface -├── pkg/ # Core packages -│ ├── config/ # Configuration management -│ ├── github/ # GitHub API integration -│ ├── platform/ # Platform-specific code -│ └── version/ # Version management -├── .golangci.yml # Linter configuration -└── .goreleaser.yml # Release configuration + +`install` downloads the release's `educates` binary for your platform and +saves it in the bin directory as `educates-`. `latest` is the +highest stable release by semver, the same release `init --download` +picks, and `install` prints which version it resolved to. `--use` makes +the version active once it is installed, and `--overwrite` downloads it +again when it is already installed. + +Every download is checked against the SHA-256 checksum the release lists +in its `checksums.txt`: + +- A mismatch fails the install. +- A `checksums.txt` that does not list the binary fails the install too, + since the binary cannot be verified. +- A release without a `checksums.txt` prints a warning on stderr and + installs the binary unverified. + +The binary downloads into a temporary file in the bin directory and is +renamed into place only once it is complete and verified. A failed or +interrupted download leaves nothing behind, and a version that was +already installed stays as it was. A download times out when the server +takes more than 30 seconds to accept the connection, 10 seconds to +complete the TLS handshake or 30 seconds to start its response, and it +fails as stalled when no data arrives for 60 seconds. Nothing caps the +transfer as a whole, so a slow download that keeps making progress +finishes. + +### Switching versions + +```sh +educatesenv use 3.7.3 ``` -### Contributing +`use` points the `educates` symlink at an installed version. When the +version is not installed, it says so and how to install it; any other +failure, such as a bin directory it cannot write to, is reported with its +cause. `use` does not resolve `latest`: to install the latest stable +release and switch to it, run `educatesenv install latest --use`. +`use develop` selects the development binary, as described under +[Development mode](#development-mode). + +### Listing installed versions + +```console +$ educatesenv list +Installed versions: +* 3.8.0 (active) + 3.7.3 +``` -Contributions are welcome! Please ensure you: +`list` shows the installed versions newest first by semver, and marks +the active one with `*`. Installed binaries whose names are not semver +versions come last, in alphabetical order. With development mode +enabled, the `develop` entry comes first. -1. Follow the code style enforced by golangci-lint -2. Include tests for new functionality -3. Update documentation as needed -4. Run `make lint` and `make test` before submitting +### Uninstalling versions -## License +```sh +educatesenv uninstall 3.7.0 3.7.1 +``` -[Apache License 2.0](LICENSE) +`uninstall` removes one or more installed versions. It checks every +version before it removes any, so a typo in one argument removes +nothing. It refuses: ---- +- a version that is not installed; +- `develop`, since educatesenv does not manage the development binary; +- the active version, unless you pass `--force`. With `--force`, the + `educates` symlink is removed with the binary, and no version is active + until you run `educatesenv use` again. -## Supported OSes +### Other commands -- macOS (amd64, arm64) -- Linux (amd64, arm64) -- Windows (amd64) - *Support in progress* +`config init` creates the default config file, as `init` does, and fails +when one exists. `config view` prints the configuration in effect, with +the GitHub token shown as where it comes from, never as the token. ---- +`version` prints the version of educatesenv itself, and `completion` +generates a shell completion script. -## Installation +## Configuration -### Automatic (Recommended) - -1. **Clone the repository:** - ```sh - git clone educatesenv - cd educatesenv - make build - ``` -2. **Initialize configuration and folders:** - ```sh - # Basic initialization - ./educatesenv init - - # Initialize and download latest version - ./educatesenv init --download - - # Initialize, download latest version and force reinstall if exists - ./educatesenv init --download --overwrite - ``` -3. **Add the bin directory to your PATH:** - - On Linux/macOS: - ```sh - echo 'export PATH="$HOME/.educatesenv/bin:$PATH"' >> ~/.bashrc - source ~/.bashrc - # or for zsh: - echo 'export PATH="$HOME/.educatesenv/bin:$PATH"' >> ~/.zprofile - source ~/.zprofile - # or for fish: - set -U fish_user_paths $HOME/.educatesenv/bin $fish_user_paths - ``` - - On Windows (PowerShell): - ```powershell - [Environment]::SetEnvironmentVariable("Path", "$HOME\.educatesenv\bin;" + $Env:Path, [EnvironmentVariableTarget]::User) - ``` - -### Manual - -1. **Download or build the binary** -2. **Create the config and bin folders:** - ```sh - mkdir -p $HOME/.educatesenv/bin - cp educatesenv $HOME/.educatesenv/bin/ - ``` -3. **Add `$HOME/.educatesenv/bin` to your PATH** (see above) - ---- +educatesenv reads its configuration from `~/.educatesenv/config.yaml` +and nowhere else: a `config.yaml` in the working directory is ignored. +The default file holds: -## Usage +```yaml +github: + org: educates + repository: educates-training-platform +local: + dir: /Users/you/.educatesenv/bin +development: + enabled: false + binaryLocation: "" +``` + +- `github.org` and `github.repository` name the repository whose releases + educatesenv lists and installs. +- `local.dir` is the bin directory, where installed versions and the + `educates` symlink live. `init` always creates `~/.educatesenv/bin` and + prints `PATH` instructions for it, so when you set `local.dir` + elsewhere, put that directory on your `PATH` instead. +- `development.enabled` and `development.binaryLocation` set up + [development mode](#development-mode). + +Keys match regardless of case, so `binarylocation` works as well as +`binaryLocation`. These environment variables override the file, and an +empty one counts as not set: + +| Variable | Overrides | +|---|---| +| `EDUCATES_GITHUB_ORG` | `github.org` | +| `EDUCATES_GITHUB_REPOSITORY` | `github.repository` | +| `EDUCATES_LOCAL_DIR` | `local.dir` | +| `EDUCATES_DEVELOPMENT_ENABLED` | `development.enabled` | +| `EDUCATES_DEVELOPMENT_BINARY_LOCATION` | `development.binaryLocation` | + +Booleans take `true` or `false`, and the other forms Go's +`strconv.ParseBool` accepts, such as `1` and `0`. In the config file, a +number other than `0` is true as well. A value in any other form, such +as `yes`, reads as false, and educatesenv prints a warning on stderr that +names the key and file, or the variable. + +### GitHub token + +educatesenv works without a token, but GitHub allows unauthenticated +clients only 60 API requests an hour, so a shared network address can +run out. With a token, the limit is far higher. educatesenv takes the +token from the first of these sources that has one: + +1. `EDUCATES_GITHUB_TOKEN`, to give educatesenv a token of its own; +2. `GH_TOKEN`; +3. `GITHUB_TOKEN`; +4. the GitHub CLI: when `gh` is on your `PATH`, the output of + `gh auth token --hostname github.com`; +5. `github.token` in the config file, which is deprecated. + +If you use the GitHub CLI, `gh auth login` is all the setup educatesenv +needs. It always asks `gh` for its github.com token, so a `GH_HOST` set +for another GitHub server does not change which token it gets. A `gh` +that is not logged in, fails or prints nothing counts as no token. + +educatesenv never writes a token to disk. Only the commands that call +GitHub, `list-remote`, `install` and `init --download`, look the token +up, and `config view`, which looks it up to show where it comes from. +The other commands never run `gh`. `config view` shows the source in +place of the token, as one of ``, +``, ``, +``, `` or +``. + +When GitHub rate-limits a request, the error says when the limit resets, +and without a token, how to authenticate. When GitHub rejects a token, +the error names the source it came from, so you know which one to fix. + +#### The config-file token is deprecated + +A token in `github.token` still works, but only as the last source, and +using it prints a warning on stderr that names the file. A later release +stops reading it. To move off it, set `GH_TOKEN` or run `gh auth login`, +then delete the `token` line from `~/.educatesenv/config.yaml`. +educatesenv no longer writes a `token` key into the files it creates. + +### Development mode + +Development mode lets the `educates` symlink point at a binary you built +yourself: + +```yaml +development: + enabled: true + binaryLocation: /path/to/your/build/educates +``` -All commands are run via the `educatesenv` binary: +With it enabled, `educatesenv use develop` selects that binary, and +`list` shows it first as `develop -> `. When you disable +development mode while the symlink still points at a development binary, +the next `list`, `use`, `install` or `init` removes the symlink and warns +you to select a version with `educatesenv use `. Other commands +never touch the symlink. -### Install a version -```sh -# Install a specific version -educatesenv install +## Development -# Install and set as active version -educatesenv install --use +### Prerequisites -# Force reinstall even if version exists -educatesenv install --force +- Go 1.26 or later. go.mod sets the language version to Go 1.26.0 and + the toolchain to go1.26.10, which CI and release builds use. A local Go + older than 1.26.10 downloads that toolchain, and a newer one builds + with itself. +- Make. +- [golangci-lint](https://golangci-lint.run/) v2.15.0, the version CI + runs. +- [GoReleaser](https://goreleaser.com/) v2.19.0, only to try the release + build locally. -# Install latest version -educatesenv install latest +### Building and testing -# Install latest version and set as active -educatesenv install latest --use +```sh +make build # build bin/educatesenv, with the version from git describe +make test # run the tests +make lint # run golangci-lint +make install # build and copy the binary to ~/.educatesenv/bin +make clean # remove bin/ +make help # list the targets +``` -# Force reinstall latest version -educatesenv install latest --force +The tests need no network access, no real `gh` and no real home +directory. They run each command in-process against a fake GitHub +server, `internal/fakegithub`, with a temporary home directory and a +`PATH` that holds at most a fake `gh` script, through the harness in +`internal/clitest`. + +### Project structure + +``` +. +├── cmd/educatesenv/ # main: builds the command tree and runs it +├── internal/ +│ ├── clitest/ # runs commands in-process for tests +│ └── fakegithub/ # fake GitHub API and release downloads for tests +├── pkg/ +│ ├── cmd/ # the cobra commands +│ ├── config/ # config file, environment overrides and paths +│ ├── github/ # GitHub API client and error messages +│ ├── platform/ # supported platforms and binary names +│ ├── releases/ # release selection and ordering rules, no I/O +│ ├── token/ # GitHub token resolution +│ └── version/ # install, use, uninstall and build version +├── .golangci.yml # linter configuration +└── .goreleaser.yml # release configuration ``` -Downloads and installs the specified version (or latest version) of `educates` into the bin directory. Use `--use` to automatically set it as the active version after installation. Use `--force` to reinstall even if the version already exists. -### List installed versions +### CI + +The workflows in `.github/workflows` take the Go version from go.mod: + +- `build.yml` runs the tests and builds the binary on Ubuntu and macOS, + on pushes and pull requests to `main` and `develop`. +- `golangci-lint.yaml` runs golangci-lint v2.15.0 on the same pushes and + pull requests. +- `release.yml` builds and publishes releases, as described below. + +Every action is pinned by the commit SHA of a release, with the release +tag in a comment, and Dependabot updates the pins weekly. The +golangci-lint and GoReleaser versions are action inputs, which Dependabot +does not update, so they change by hand. + +## Releasing + +Pushing a tag such as `v1.1.0` runs the release workflow: + ```sh -educatesenv list +git tag v1.1.0 +git push origin v1.1.0 ``` -Lists all installed `educates` binaries. The active version is marked with `*`. -### Use a version +GoReleaser v2.19.0 builds the binaries for macOS and Linux on amd64 and +arm64, names them `educatesenv--`, writes `checksums.txt`, +signs and notarizes the macOS binaries, and creates a draft release. + +### Signing and notarization + +The macOS binaries are signed with the "Developer ID Application: Jorge +Morales Pou (XNSN5MMC5C)" certificate, which was issued by Apple's +Developer ID Certification Authority G2 intermediate and expires on +2030-05-16. Renew it before then. Apple's retirement of the original +Developer ID intermediate on 2027-02-01 does not affect it. + +Signing a binary needs a valid certificate, and notarizing it needs an +active Apple Developer Program membership: when the membership lapses, +Apple's notary service stops accepting submissions. Binaries signed +before the certificate or the membership expired keep working; a revoked +certificate stops them from launching. + +The workflow reads the certificate and the notary key from these +repository secrets: + +| Secret | Holds | +|---|---| +| `MACOS_SIGN_P12` | the base64 of the `.p12` certificate | +| `MACOS_SIGN_PASSWORD` | the password of the `.p12` certificate | +| `MACOS_NOTARY_KEY` | the base64 of the App Store Connect `.p8` key | +| `MACOS_NOTARY_KEY_ID` | the ID of the `.p8` key | +| `MACOS_NOTARY_ISSUER_ID` | the issuer UUID of the `.p8` key | + +Every release signs and notarizes the macOS binaries, except a manual run +with the `skip-signing` input and any run while the `MACOS_SIGNING` +repository variable is `false`, both described below. Its first step +checks the +five secrets and fails, naming the ones that are empty or missing. The +run also fails when signing fails or when the notarization submission +fails. GoReleaser signs and notarizes before it creates the draft +release, so a failed run leaves the tag but no release behind, and a +manual run with `skip-signing` can release that tag. A run that fails +later, while uploading, leaves a draft, which the next run for the same +tag deletes and creates again. + +The `MACOS_SIGNING` environment variable switches signing and +notarization, for `goreleaser release` and `goreleaser build` alike. A +release signs unless `MACOS_SIGNING` is `false`, and a snapshot signs +only when it is `true`. So `goreleaser release --snapshot --clean` builds +without the certificate, and +`MACOS_SIGNING=true goreleaser release --snapshot --clean` tests signing +with it. The release workflow sets it to `false` for a manual run with +`skip-signing` or while the `MACOS_SIGNING` repository variable is +`false`, and to `true` for every other run. + +### Publishing the draft + +The workflow submits the macOS binaries for notarization without waiting +for Apple's verdict, so a release stays a draft until Apple's +notarization email arrives. Once the email confirms that notarization +succeeded, edit the draft's notes and publish it. + +### Releasing unsigned + +When the membership or the certificate is unavailable, release without +signing from the workflow's manual trigger. In the Actions tab, run the +Release workflow with `release_tag` set to the tag and `skip-signing` +ticked, or with the GitHub CLI: + ```sh -educatesenv use +gh workflow run release.yml -f release_tag=v1.1.0 -f skip-signing=true ``` -Switches the active `educates` binary by updating the `educates` symlink in the bin directory. -### List remote versions +The workflow then sets `MACOS_SIGNING=false` and skips the secrets +check, and every platform ships, the macOS binaries unsigned. No secret +or variable needs to change. There is no +notarization email to wait for, so publish the draft once its notes are +ready, and point macOS users who download in a browser to the quarantine +note under [Installation](#macos-and-the-quarantine-attribute). Only the +manual trigger offers `skip-signing`. + +To leave signing off for every release, tag pushes included, until the +membership or the certificate is back, set the `MACOS_SIGNING` repository +variable to `false`: + ```sh -educatesenv list-remote [--skip-pre-releases] +gh variable set MACOS_SIGNING --body false --repo educates/educatesenv ``` -Lists all available versions from the [educates GitHub releases](https://github.com/educates/educates-training-platform/releases). Use `--skip-pre-releases` to hide alpha, beta, and rc versions. -### Uninstall a version +Every run then releases unsigned and adds a warning to its summary +saying so. Delete the variable to sign again: + ```sh -educatesenv uninstall -``` \ No newline at end of file +gh variable delete MACOS_SIGNING --repo educates/educatesenv +``` + +## Contributing + +Contributions are welcome. Before you open a pull request, run +`make lint` and `make test`, and add tests for new behavior. + +## License + +[Apache License 2.0](LICENSE) diff --git a/cmd/educatesenv/main.go b/cmd/educatesenv/main.go index dabc445..460ff35 100644 --- a/cmd/educatesenv/main.go +++ b/cmd/educatesenv/main.go @@ -8,8 +8,9 @@ import ( ) func main() { - if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + root := cmd.NewRootCommand() + if err := root.Execute(); err != nil { + _, _ = fmt.Fprintf(root.ErrOrStderr(), "Error: %v\n", err) os.Exit(1) } } diff --git a/go.mod b/go.mod index cb93688..0cb32d0 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,19 @@ module github.com/educates/educatesenv -go 1.24 +go 1.26.0 + +toolchain go1.26.10 require ( - github.com/google/go-github/v71 v71.0.0 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 - golang.org/x/oauth2 v0.29.0 - gopkg.in/yaml.v3 v3.0.1 + github.com/google/go-github/v93 v93.0.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/mod v0.42.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index bbc57b7..8849725 100644 --- a/go.sum +++ b/go.sum @@ -1,66 +1,23 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-github/v93 v93.0.0 h1:qElXPLv+XbSz1qrLdWyb0t3gSYGsWBTzr9T4/PZTCfk= +github.com/google/go-github/v93 v93.0.0/go.mod h1:l8I3rt4/mdjANKpg4Y84LEy9HJQCetUkZ8WTl2bPU8M= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/clitest/clitest.go b/internal/clitest/clitest.go new file mode 100644 index 0000000..be0a934 --- /dev/null +++ b/internal/clitest/clitest.go @@ -0,0 +1,201 @@ +// Package clitest runs educatesenv commands in-process, isolated from the +// machine they run on. +// +// New gives each test a temporary home directory and working directory, a +// PATH that holds only an optional fake gh script, an environment without +// the variables educatesenv or gh read, and a fake GitHub server. Run then +// builds a fresh command tree pointed at that server, runs it with the +// given arguments, and returns what it wrote and the error it returned. +// +// The environment is changed with t.Setenv, so tests that use an Env cannot +// run in parallel. +package clitest + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/educates/educatesenv/internal/fakegithub" + "github.com/educates/educatesenv/pkg/cmd" +) + +// scrubbedPrefixes and scrubbedVariables name the environment variables +// New removes, so that the developer's own settings and tokens never reach +// a test. +var ( + scrubbedPrefixes = []string{"EDUCATES_"} + scrubbedVariables = []string{ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + "GH_CONFIG_DIR", + "XDG_CONFIG_HOME", + "SHELL", + } +) + +// Env is an isolated environment for running educatesenv commands. +type Env struct { + t *testing.T + + // Home is the temporary home directory, set as HOME. + Home string + // WorkDir is the temporary working directory the commands run in. + WorkDir string + // PathDir is the only directory on PATH. It holds nothing until FakeGH + // writes a gh script into it. + PathDir string + // GitHub is the fake GitHub server the commands call. + GitHub *fakegithub.Server + // DownloadIdleTimeout, when not zero, is how long a download in the + // commands Run builds waits for data before it fails, in place of the + // 60 seconds it waits outside tests. + DownloadIdleTimeout time.Duration +} + +// Result is what one run of a command tree produced. +type Result struct { + Stdout string + Stderr string + Err error +} + +// New sets up an isolated environment for the test. +func New(t *testing.T) *Env { + t.Helper() + + e := &Env{ + t: t, + Home: t.TempDir(), + WorkDir: t.TempDir(), + PathDir: t.TempDir(), + GitHub: fakegithub.New(t), + } + + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + for _, prefix := range scrubbedPrefixes { + if strings.HasPrefix(key, prefix) { + e.Unsetenv(key) + } + } + } + for _, key := range scrubbedVariables { + e.Unsetenv(key) + } + + t.Setenv("HOME", e.Home) + t.Setenv("PATH", e.PathDir) + t.Chdir(e.WorkDir) + return e +} + +// Run builds a fresh command tree and runs it as `educatesenv `. +func (e *Env) Run(args ...string) Result { + e.t.Helper() + + root := cmd.NewRootCommand( + cmd.WithGitHubBaseURL(e.GitHub.URL), + cmd.WithDownloadIdleTimeout(e.DownloadIdleTimeout), + ) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetIn(strings.NewReader("")) + // cobra reads os.Args when the arguments are nil. + root.SetArgs(append([]string{}, args...)) + + err := root.Execute() + return Result{Stdout: stdout.String(), Stderr: stderr.String(), Err: err} +} + +// Setenv sets an environment variable for the rest of the test. +func (e *Env) Setenv(key, value string) { + e.t.Helper() + e.t.Setenv(key, value) +} + +// Unsetenv removes an environment variable for the rest of the test. +func (e *Env) Unsetenv(key string) { + e.t.Helper() + // t.Setenv restores the original value when the test ends. + e.t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + e.t.Fatalf("unset %s: %v", key, err) + } +} + +// FakeGH writes an executable gh script into PathDir, so commands find it +// on PATH. script is the body of a /bin/sh script. PATH holds only +// PathDir, so the script can use shell builtins and absolute paths only. +func (e *Env) FakeGH(script string) { + e.t.Helper() + path := filepath.Join(e.PathDir, "gh") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { + e.t.Fatalf("write fake gh: %v", err) + } +} + +// ConfigDir returns the educatesenv directory in the home directory. +func (e *Env) ConfigDir() string { + return filepath.Join(e.Home, ".educatesenv") +} + +// BinDir returns the default bin directory, where versions are installed. +func (e *Env) BinDir() string { + return filepath.Join(e.ConfigDir(), "bin") +} + +// WriteConfig writes content as the config file in ConfigDir and returns +// its path. +func (e *Env) WriteConfig(content string) string { + e.t.Helper() + path := filepath.Join(e.ConfigDir(), "config.yaml") + e.writeFile(path, []byte(content), 0o600) + return path +} + +// WriteBinary writes an executable that stands in for an installed +// version into BinDir, and returns its path. Its content is +// "educates ". +func (e *Env) WriteBinary(version string) string { + e.t.Helper() + path := filepath.Join(e.BinDir(), "educates-"+version) + e.writeFile(path, []byte("educates "+version), 0o755) + return path +} + +// ActiveLink returns the path of the educates symlink in BinDir, which +// points at the active version. +func (e *Env) ActiveLink() string { + return filepath.Join(e.BinDir(), "educates") +} + +// PlatformBinaryName returns the name of the release asset that holds the +// educates binary for the platform the tests run on. +func PlatformBinaryName() string { + return "educates-" + runtime.GOOS + "-" + runtime.GOARCH +} + +// PlatformBinary returns a release asset holding content as the educates +// binary for the platform the tests run on. +func PlatformBinary(content []byte) fakegithub.Asset { + return fakegithub.Asset{Name: PlatformBinaryName(), Content: content} +} + +func (e *Env) writeFile(path string, content []byte, perm os.FileMode) { + e.t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + e.t.Fatalf("create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, content, perm); err != nil { + e.t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/fakegithub/downloads.go b/internal/fakegithub/downloads.go new file mode 100644 index 0000000..0c88d4b --- /dev/null +++ b/internal/fakegithub/downloads.go @@ -0,0 +1,132 @@ +package fakegithub + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// Checksums returns a checksums.txt asset that lists the SHA-256 checksum +// of each of assets, in the format sha256sum writes and Educates releases +// publish. +func Checksums(assets ...Asset) Asset { + var b strings.Builder + for _, a := range assets { + sum := sha256.Sum256(a.Content) + b.WriteString(hex.EncodeToString(sum[:]) + " " + a.Name + "\n") + } + return Asset{Name: "checksums.txt", Content: []byte(b.String())} +} + +// CutNextDownload makes the next download of the asset name of the release +// tagged tag fail mid-transfer: the response announces the asset's full +// length and sends the first half of it, then the server drops the +// connection. Later downloads of the asset are served whole. Calling it +// twice for the same asset panics, as Handle does. +func (s *Server) CutNextDownload(tag, name string) { + s.interruptNextDownload(tag, name, func(w http.ResponseWriter, _ *http.Request) { + conn, _, err := http.NewResponseController(w).Hijack() + if err != nil { + panic("fakegithub: cannot drop the connection: " + err.Error()) + } + _ = conn.Close() + }) +} + +// stallLimit is how long a stalled download sends nothing before the +// server drops the connection, so that a client that never gives up fails +// instead of hanging the test. +const stallLimit = 10 * time.Second + +// StallNextDownload makes the next download of the asset name of the +// release tagged tag stall mid-transfer: the response announces the +// asset's full length and sends the first half of it, then sends nothing +// more and keeps the connection open until the client gives up. After 10 +// seconds the server drops the connection. Later downloads of the asset +// are served whole. Calling it twice for the same asset panics, as Handle +// does. +func (s *Server) StallNextDownload(tag, name string) { + s.interruptNextDownload(tag, name, func(_ http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(stallLimit): + } + }) +} + +// TrickleDownloads makes every download of the asset name of the release +// tagged tag arrive slowly: the response announces the asset's full length +// and sends it in chunks pieces of about the same size, waiting interval +// before each one, so the transfer takes chunks times interval in all. +// Calling it twice for the same asset panics, as Handle does. +func (s *Server) TrickleDownloads(tag, name string, chunks int, interval time.Duration) { + s.Handle("GET /download/"+tag+"/"+name, func(w http.ResponseWriter, r *http.Request) { + content, ok := s.assetContent(tag, name) + if !ok { + notFound(w) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + rc := http.NewResponseController(w) + _ = rc.Flush() + for i := range chunks { + select { + case <-r.Context().Done(): + return + case <-time.After(interval): + } + _, _ = w.Write(content[i*len(content)/chunks : (i+1)*len(content)/chunks]) + _ = rc.Flush() + } + }) +} + +// interruptNextDownload makes the next download of the asset name of the +// release tagged tag announce the asset's full length and send the first +// half of it, then calls interrupt to end the transfer. Later downloads of +// the asset are served whole. +func (s *Server) interruptNextDownload(tag, name string, interrupt http.HandlerFunc) { + var interrupted atomic.Bool + s.Handle("GET /download/"+tag+"/"+name, func(w http.ResponseWriter, r *http.Request) { + if interrupted.Swap(true) { + s.routes.ServeHTTP(w, r) + return + } + + content, ok := s.assetContent(tag, name) + if !ok { + notFound(w) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content[:len(content)/2]) + _ = http.NewResponseController(w).Flush() + interrupt(w, r) + }) +} + +// assetContent returns the content of the asset name of the release tagged +// tag. +func (s *Server) assetContent(tag, name string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, stored := range s.releases { + if stored.release.Tag != tag { + continue + } + for _, a := range stored.assets { + if a.asset.Name == name { + return a.asset.Content, true + } + } + } + return nil, false +} diff --git a/internal/fakegithub/downloads_test.go b/internal/fakegithub/downloads_test.go new file mode 100644 index 0000000..219c616 --- /dev/null +++ b/internal/fakegithub/downloads_test.go @@ -0,0 +1,110 @@ +package fakegithub_test + +import ( + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/fakegithub" +) + +func get(t *testing.T, url string) (*http.Response, []byte, error) { + t.Helper() + return getWith(t, http.DefaultClient, url) +} + +func getWith(t *testing.T, client *http.Client, url string) (*http.Response, []byte, error) { + t.Helper() + resp, err := client.Get(url) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + return resp, body, err +} + +func TestChecksumsListsEachAssetsSHA256AsSha256sumDoes(t *testing.T) { + sums := fakegithub.Checksums( + fakegithub.Asset{Name: "educates-linux-amd64", Content: []byte("educates 3.8.0 binary")}, + fakegithub.Asset{Name: "educates-darwin-arm64", Content: []byte("educates 3.8.0")}, + ) + + assert.Equal(t, "checksums.txt", sums.Name) + assert.Equal(t, "21163080cb987ff30bc789604b421676c7065e47b4c77415af76459db9bfc4bd educates-linux-amd64\n"+ + "5e990e9569bdb6a9a588600567e649ae1a63aa23b387ef9010b05dafada7657c educates-darwin-arm64\n", string(sums.Content)) +} + +func TestCutsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.CutNextDownload("3.8.0", "educates-linux-amd64") + + resp, body, err := get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + assert.Equal(t, "01234", string(body)) + + resp, body, err = get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "0123456789", string(body)) +} + +func TestStallsTheNextDownloadMidTransferAndServesLaterOnesWhole(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.StallNextDownload("3.8.0", "educates-linux-amd64") + + client := &http.Client{Timeout: 200 * time.Millisecond} + resp, body, err := getWith(t, client, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + var netErr net.Error + require.ErrorAs(t, err, &netErr) + assert.True(t, netErr.Timeout(), "the download times out instead of failing: %v", err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + assert.Equal(t, "01234", string(body)) + + resp, body, err = get(t, srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "0123456789", string(body)) +} + +func TestTricklesADownloadInChunksSpacedApart(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("0123456789")}, + }}) + srv.TrickleDownloads("3.8.0", "educates-linux-amd64", 5, 20*time.Millisecond) + + resp, err := http.Get(srv.DownloadURL("3.8.0", "educates-linux-amd64")) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int64(10), resp.ContentLength) + + var chunks []string + start := time.Now() + buf := make([]byte, 10) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + chunks = append(chunks, string(buf[:n])) + } + if err == io.EOF { + break + } + require.NoError(t, err) + } + assert.Equal(t, []string{"01", "23", "45", "67", "89"}, chunks) + assert.GreaterOrEqual(t, time.Since(start), 5*20*time.Millisecond) +} diff --git a/internal/fakegithub/server.go b/internal/fakegithub/server.go new file mode 100644 index 0000000..54226f5 --- /dev/null +++ b/internal/fakegithub/server.go @@ -0,0 +1,311 @@ +// Package fakegithub serves the parts of the GitHub REST API that +// educatesenv uses, from releases a test sets up, on an httptest server. +// +// It serves one repository, educates/educates-training-platform unless +// SetRepository changes it: +// +// - GET /repos/{owner}/{repo}/releases lists the releases in the order +// they were added, paginated with page and per_page and a Link header, +// as GitHub does. +// - GET /repos/{owner}/{repo}/releases/tags/{tag} returns one release. +// - GET /download/{tag}/{name} serves an asset's content. Each asset's +// browser_download_url points there. +// +// Anything else, including another repository, gets GitHub's 404 body. +// Handle registers a handler that takes precedence over these, for +// responses a test needs that the defaults do not give. +package fakegithub + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + "time" +) + +const ( + // DefaultOwner is the owner of the repository the server serves by default. + DefaultOwner = "educates" + // DefaultRepository is the repository the server serves by default. + DefaultRepository = "educates-training-platform" + + defaultPerPage = 30 + maxPerPage = 100 +) + +// Release is a GitHub release the server serves. +type Release struct { + Tag string + // PublishedAt is served as published_at. The zero time is served as + // null, as GitHub does for drafts. + PublishedAt time.Time + Prerelease bool + Draft bool + Assets []Asset +} + +// Asset is a file attached to a release. +type Asset struct { + Name string + Content []byte +} + +// Request is a request the server received. +type Request struct { + Method string + Path string + Query url.Values + Header http.Header +} + +// Server is a fake GitHub API on an httptest server. +type Server struct { + // URL is the server's base URL, with no trailing slash. + URL string + + mu sync.Mutex + owner string + repo string + releases []storedRelease + nextID int64 + requests []Request + overrides *http.ServeMux + routes *http.ServeMux +} + +type storedRelease struct { + id int64 + release Release + assets []storedAsset +} + +type storedAsset struct { + id int64 + asset Asset +} + +// New starts a server that serves no releases yet. It is closed when the +// test ends. +func New(t testing.TB) *Server { + t.Helper() + + s := &Server{ + owner: DefaultOwner, + repo: DefaultRepository, + overrides: http.NewServeMux(), + routes: http.NewServeMux(), + } + s.routes.HandleFunc("GET /repos/{owner}/{repo}/releases", s.listReleases) + s.routes.HandleFunc("GET /repos/{owner}/{repo}/releases/tags/{tag}", s.releaseByTag) + s.routes.HandleFunc("GET /download/{tag}/{name}", s.download) + s.routes.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { notFound(w) }) + + srv := httptest.NewServer(http.HandlerFunc(s.serveHTTP)) + t.Cleanup(srv.Close) + s.URL = srv.URL + return s +} + +// SetRepository changes the repository the server serves. +func (s *Server) SetRepository(owner, repo string) { + s.mu.Lock() + defer s.mu.Unlock() + s.owner, s.repo = owner, repo +} + +// AddReleases adds releases after the ones already added. Lists serve them +// in that order, so add them newest first, as GitHub lists them. +func (s *Server) AddReleases(releases ...Release) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range releases { + s.nextID++ + stored := storedRelease{id: s.nextID, release: r} + for _, a := range r.Assets { + s.nextID++ + stored.assets = append(stored.assets, storedAsset{id: s.nextID, asset: a}) + } + s.releases = append(s.releases, stored) + } +} + +// Handle registers handler for pattern, a net/http ServeMux pattern such +// as "GET /download/3.8.0/checksums.txt". A request that matches a pattern +// registered here gets that handler instead of the default response. +// Registering the same pattern twice panics, as ServeMux does. +func (s *Server) Handle(pattern string, handler http.HandlerFunc) { + s.overrides.Handle(pattern, handler) +} + +// DownloadURL returns the URL the server serves an asset at. +func (s *Server) DownloadURL(tag, name string) string { + return s.URL + "/download/" + url.PathEscape(tag) + "/" + url.PathEscape(name) +} + +// Requests returns the requests the server received, in order. +func (s *Server) Requests() []Request { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Request(nil), s.requests...) +} + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.requests = append(s.requests, Request{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Header: r.Header.Clone(), + }) + s.mu.Unlock() + + if _, pattern := s.overrides.Handler(r); pattern != "" { + s.overrides.ServeHTTP(w, r) + return + } + s.routes.ServeHTTP(w, r) +} + +func (s *Server) listReleases(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.servesRepository(r) { + notFound(w) + return + } + + perPage := queryInt(r, "per_page", defaultPerPage) + perPage = min(max(perPage, 1), maxPerPage) + page := max(queryInt(r, "page", 1), 1) + lastPage := max((len(s.releases)+perPage-1)/perPage, 1) + + start := min((page-1)*perPage, len(s.releases)) + end := min(start+perPage, len(s.releases)) + body := make([]releaseJSON, 0, end-start) + for _, stored := range s.releases[start:end] { + body = append(body, s.toJSON(stored)) + } + + if page < lastPage { + pageURL := func(p int) string { + q := url.Values{"per_page": {strconv.Itoa(perPage)}, "page": {strconv.Itoa(p)}} + return fmt.Sprintf("%s%s?%s", s.URL, r.URL.Path, q.Encode()) + } + w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next", <%s>; rel="last"`, pageURL(page+1), pageURL(lastPage))) + } + writeJSON(w, http.StatusOK, body) +} + +func (s *Server) releaseByTag(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.servesRepository(r) { + notFound(w) + return + } + + tag := r.PathValue("tag") + for _, stored := range s.releases { + if stored.release.Tag == tag { + writeJSON(w, http.StatusOK, s.toJSON(stored)) + return + } + } + notFound(w) +} + +func (s *Server) download(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + tag, name := r.PathValue("tag"), r.PathValue("name") + for _, stored := range s.releases { + if stored.release.Tag != tag { + continue + } + for _, a := range stored.assets { + if a.asset.Name == name { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(a.asset.Content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(a.asset.Content) + return + } + } + } + notFound(w) +} + +func (s *Server) servesRepository(r *http.Request) bool { + return r.PathValue("owner") == s.owner && r.PathValue("repo") == s.repo +} + +type releaseJSON struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + PublishedAt *time.Time `json:"published_at"` + Assets []assetJSON `json:"assets"` +} + +type assetJSON struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int `json:"size"` + ContentType string `json:"content_type"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (s *Server) toJSON(stored storedRelease) releaseJSON { + rel := stored.release + out := releaseJSON{ + ID: stored.id, + TagName: rel.Tag, + Name: rel.Tag, + Draft: rel.Draft, + Prerelease: rel.Prerelease, + Assets: make([]assetJSON, 0, len(stored.assets)), + } + if !rel.PublishedAt.IsZero() { + published := rel.PublishedAt.UTC() + out.PublishedAt = &published + } + for _, a := range stored.assets { + out.Assets = append(out.Assets, assetJSON{ + ID: a.id, + Name: a.asset.Name, + Size: len(a.asset.Content), + ContentType: "application/octet-stream", + BrowserDownloadURL: s.DownloadURL(rel.Tag, a.asset.Name), + }) + } + return out +} + +func queryInt(r *http.Request, key string, fallback int) int { + n, err := strconv.Atoi(r.URL.Query().Get(key)) + if err != nil { + return fallback + } + return n +} + +func notFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest", + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/fakegithub/server_test.go b/internal/fakegithub/server_test.go new file mode 100644 index 0000000..da14218 --- /dev/null +++ b/internal/fakegithub/server_test.go @@ -0,0 +1,164 @@ +package fakegithub_test + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v93/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/fakegithub" +) + +const ( + owner = fakegithub.DefaultOwner + repo = fakegithub.DefaultRepository +) + +func newClient(t *testing.T, srv *fakegithub.Server, opts ...github.ClientOptionsFunc) *github.Client { + t.Helper() + baseURL := srv.URL + client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(&baseURL, nil)}, opts...)...) + require.NoError(t, err) + return client +} + +func TestListsEveryReleaseAcrossPagesInTheOrderAdded(t *testing.T) { + srv := fakegithub.New(t) + var want []string + for i := 250; i > 0; i-- { + tag := fmt.Sprintf("1.0.%d", i) + want = append(want, tag) + srv.AddReleases(fakegithub.Release{Tag: tag}) + } + client := newClient(t, srv) + + var got []string + var pages int + opt := &github.ListOptions{PerPage: 100} + for { + releases, resp, err := client.Repositories.ListReleases(context.Background(), owner, repo, opt) + require.NoError(t, err) + pages++ + for _, r := range releases { + got = append(got, r.GetTagName()) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + assert.Equal(t, 3, pages) + assert.Equal(t, want, got) +} + +func TestServesReleaseFieldsAndAssets(t *testing.T) { + srv := fakegithub.New(t) + published := time.Date(2026, 8, 26, 14, 30, 0, 0, time.UTC) + srv.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.13", Prerelease: true, PublishedAt: published.Add(time.Hour)}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: published, Assets: []fakegithub.Asset{ + {Name: "educates-linux-amd64", Content: []byte("binary")}, + {Name: "checksums.txt", Content: []byte("sums")}, + }}, + fakegithub.Release{Tag: "3.9.0", Draft: true}, + ) + client := newClient(t, srv) + + releases, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.NoError(t, err) + require.Len(t, releases, 3) + assert.True(t, releases[0].GetPrerelease()) + assert.True(t, releases[2].GetDraft()) + assert.Nil(t, releases[2].PublishedAt) + + release, _, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "3.8.0") + require.NoError(t, err) + assert.Equal(t, "3.8.0", release.GetTagName()) + assert.False(t, release.GetPrerelease()) + assert.Equal(t, published, release.GetPublishedAt().UTC()) + require.Len(t, release.Assets, 2) + assert.Equal(t, "educates-linux-amd64", release.Assets[0].GetName()) + assert.Equal(t, srv.DownloadURL("3.8.0", "educates-linux-amd64"), release.Assets[0].GetBrowserDownloadURL()) + + resp, err := http.Get(release.Assets[0].GetBrowserDownloadURL()) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "binary", string(body)) +} + +func TestAnswersNotFoundForUnknownTagsAssetsAndRepositories(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + client := newClient(t, srv) + + _, resp, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "9.9.9") + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + _, resp, err = client.Repositories.ListReleases(context.Background(), "someone", "else", nil) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + download, err := http.Get(srv.DownloadURL("3.8.0", "missing")) + require.NoError(t, err) + _ = download.Body.Close() + assert.Equal(t, http.StatusNotFound, download.StatusCode) +} + +func TestServesTheRepositorySetForIt(t *testing.T) { + srv := fakegithub.New(t) + srv.SetRepository("acme", "tools") + srv.AddReleases(fakegithub.Release{Tag: "1.0.0"}) + client := newClient(t, srv) + + releases, _, err := client.Repositories.ListReleases(context.Background(), "acme", "tools", nil) + require.NoError(t, err) + assert.Len(t, releases, 1) + + _, resp, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestHandlersRegisteredByATestTakePrecedence(t *testing.T) { + srv := fakegithub.New(t) + srv.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + srv.Handle("GET /repos/{owner}/{repo}/releases/tags/3.8.0", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) + }) + client := newClient(t, srv) + + _, resp, err := client.Repositories.GetReleaseByTag(context.Background(), owner, repo, "3.8.0") + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + releases, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, nil) + require.NoError(t, err) + assert.Len(t, releases, 1) +} + +func TestRecordsTheRequestsItReceives(t *testing.T) { + srv := fakegithub.New(t) + client := newClient(t, srv, github.WithAuthToken("tok123")) + + _, _, err := client.Repositories.ListReleases(context.Background(), owner, repo, &github.ListOptions{PerPage: 100}) + require.NoError(t, err) + + requests := srv.Requests() + require.Len(t, requests, 1) + assert.Equal(t, http.MethodGet, requests[0].Method) + assert.Equal(t, "/repos/educates/educates-training-platform/releases", requests[0].Path) + assert.Equal(t, "100", requests[0].Query.Get("per_page")) + assert.Equal(t, "Bearer tok123", requests[0].Header.Get("Authorization")) +} diff --git a/pkg/cmd/args_test.go b/pkg/cmd/args_test.go new file mode 100644 index 0000000..5373316 --- /dev/null +++ b/pkg/cmd/args_test.go @@ -0,0 +1,38 @@ +package cmd_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestCommandsWithoutArgumentsRejectStrayOnes(t *testing.T) { + tests := [][]string{ + {"list-remote"}, + {"list"}, + {"list-local"}, + {"init"}, + {"config"}, + {"config", "init"}, + {"config", "view"}, + {"version"}, + } + + for _, command := range tests { + name := strings.Join(command, " ") + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + + res := env.Run(append(command, "stray")...) + + require.EqualError(t, res.Err, `unknown command "stray" for "educatesenv `+name+`"`) + assert.Empty(t, res.Stdout) + assert.NoDirExists(t, env.ConfigDir()) + assert.Empty(t, env.GitHub.Requests()) + }) + } +} diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index 25be414..9a7d7b1 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -2,86 +2,74 @@ package cmd import ( "fmt" - "os" - "path/filepath" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/educates/educatesenv/pkg/config" ) -var configCmd = &cobra.Command{ - Use: "config", - Short: "Manage educatesenv configuration", - SilenceErrors: true, - SilenceUsage: true, +func newConfigCommand(d *deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage educatesenv configuration", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + // cobra checks the arguments only of a command that can run, so + // config runs and prints its help. + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newConfigInitCommand(), newConfigViewCommand(d)) + return cmd } -var configInitCmd = &cobra.Command{ - Use: "init", - Short: "Generate a default config file", - // Disable the automatic error printing - SilenceErrors: true, - // Disable automatic usage printing on error - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - configDir := filepath.Join(home, config.ConfigDirName) - configPath := filepath.Join(configDir, "config.yaml") - - // Check if config already exists - if _, err := os.Stat(configPath); err == nil { - return fmt.Errorf("config file already exists at %s", configPath) - } - - // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // Create new config with defaults - configFile := config.New() - - // Write configuration to file - yamlBytes, err := yaml.Marshal(&configFile) - if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) - } - - if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - fmt.Printf("Config file created at %s\n", configPath) - return nil - }, +func newConfigInitCommand() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Generate a default config file", + Args: cobra.NoArgs, + // Disable the automatic error printing + SilenceErrors: true, + // Disable automatic usage printing on error + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + paths, created, err := config.CreateDefault() + if err != nil { + return err + } + if !created { + return fmt.Errorf("config file already exists at %s", paths.ConfigFile) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config file created at %s\n", paths.ConfigFile) + return nil + }, + } } -var configViewCmd = &cobra.Command{ - Use: "view", - Short: "Show the current configuration", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Convert config struct to YAML - yamlBytes, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) - } - - // Print the configuration - fmt.Println("Current configuration:") - fmt.Println(string(yamlBytes)) - return nil - }, -} +func newConfigViewCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "view", + Short: "Show the current configuration", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + // The token is shown as its source, never as the token. + view := *d.cfg + view.Github.Token = d.githubToken().String() + yamlBytes, err := yaml.Marshal(view) + if err != nil { + return fmt.Errorf("failed to marshal config to YAML: %w", err) + } -func init() { - configCmd.AddCommand(configInitCmd) - configCmd.AddCommand(configViewCmd) - rootCmd.AddCommand(configCmd) + // Print the configuration + out := cmd.OutOrStdout() + _, _ = fmt.Fprintln(out, "Current configuration:") + _, _ = fmt.Fprintln(out, string(yamlBytes)) + return nil + }, + } } diff --git a/pkg/cmd/config_test.go b/pkg/cmd/config_test.go new file mode 100644 index 0000000..f449421 --- /dev/null +++ b/pkg/cmd/config_test.go @@ -0,0 +1,369 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// configView runs `config view` and returns what it printed. +func configView(t *testing.T, env *clitest.Env) string { + t.Helper() + res := env.Run("config", "view") + require.NoError(t, res.Err) + return res.Stdout +} + +func TestConfigWithoutASubcommandPrintsItsHelp(t *testing.T) { + env := clitest.New(t) + + res := env.Run("config") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Available Commands:\n") + assert.Contains(t, res.Stdout, " init ") + assert.Contains(t, res.Stdout, " view ") + assert.NoDirExists(t, env.ConfigDir()) +} + +func TestConfigViewPrintsTheConfiguration(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Regexp(t, `^Current configuration:\ngithub:\n org: acme\n`, res.Stdout) + assert.Empty(t, res.Stderr) +} + +func TestConfigViewShowsTheDefaultsWithoutAConfigFile(t *testing.T) { + env := clitest.New(t) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " repository: educates-training-platform\n") + assert.Contains(t, out, " dir: "+env.BinDir()+"\n") + assert.Contains(t, out, " enabled: false\n") + assert.Contains(t, out, " binaryLocation: \"\"\n") +} + +func TestAConfigFileWithoutValuesLeavesTheDefaults(t *testing.T) { + for name, content := range map[string]string{ + "empty": "", + "comments only": "# educatesenv\n", + "an empty section": "github:\n", + } { + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(content) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " dir: "+env.BinDir()+"\n") + }) + } +} + +func TestEachRunReadsTheConfigAfresh(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + res := env.Run("config", "view") + require.NoError(t, res.Err) + require.Contains(t, res.Stdout, "org: acme\n") + + require.NoError(t, os.Remove(configPath)) + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "org: educates\n") +} + +func TestAMissingHomeDirectoryWarnsOnce(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "Warning: could not determine home directory, using current directory for bin folder.\ndevelop\n", res.Stdout) +} + +func TestAConfigFileInTheWorkingDirectoryIsIgnored(t *testing.T) { + const workDirConfig = "github:\n org: elsewhere\ndevelopment:\n enabled: true\n binaryLocation: /tmp/educates\n" + + t.Run("with a config in the home directory", func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) + + t.Run("without a config in the home directory", func(t *testing.T) { + env := clitest.New(t) + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) + + t.Run("without a home directory", func(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, "config.yaml"), []byte(workDirConfig), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(env.WorkDir, ".educatesenv"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(env.WorkDir, ".educatesenv", "config.yaml"), []byte(workDirConfig), 0o600)) + + out := configView(t, env) + + assert.Contains(t, out, " org: educates\n") + assert.Contains(t, out, " dir: "+filepath.Join(".educatesenv", "bin")+"\n") + assert.Contains(t, out, " enabled: false\n") + assert.NotContains(t, out, "/tmp/educates") + }) +} + +func TestConfigKeysMatchRegardlessOfCase(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(`GitHub: + ORG: acme + Repository: widgets +LOCAL: + Dir: /opt/educates/bin +Development: + ENABLED: true + binarylocation: /src/educates/educates +`) + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") + assert.Contains(t, out, " repository: widgets\n") + assert.Contains(t, out, " dir: /opt/educates/bin\n") + assert.Contains(t, out, " enabled: true\n") + assert.Contains(t, out, " binaryLocation: /src/educates/educates\n") +} + +func TestEnvironmentVariablesOverrideTheConfigFile(t *testing.T) { + tests := []struct { + variable string + value string + fromFile string + fromEnv string + }{ + {"EDUCATES_GITHUB_ORG", "envorg", " org: fileorg\n", " org: envorg\n"}, + {"EDUCATES_GITHUB_REPOSITORY", "envrepo", " repository: filerepo\n", " repository: envrepo\n"}, + {"EDUCATES_LOCAL_DIR", "/env/bin", " dir: /file/bin\n", " dir: /env/bin\n"}, + {"EDUCATES_DEVELOPMENT_ENABLED", "false", " enabled: true\n", " enabled: false\n"}, + {"EDUCATES_DEVELOPMENT_BINARY_LOCATION", "/env/educates", " binaryLocation: /file/educates\n", " binaryLocation: /env/educates\n"}, + } + + for _, tt := range tests { + t.Run(tt.variable, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig(`github: + org: fileorg + repository: filerepo +local: + dir: /file/bin +development: + enabled: true + binaryLocation: /file/educates +`) + env.Setenv(tt.variable, tt.value) + + out := configView(t, env) + + assert.Contains(t, out, tt.fromEnv) + assert.NotContains(t, out, tt.fromFile) + }) + } +} + +func TestAnEmptyEnvironmentVariableLeavesTheConfigFileValue(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("github:\n org: acme\n") + env.Setenv("EDUCATES_GITHUB_ORG", "") + + out := configView(t, env) + + assert.Contains(t, out, " org: acme\n") +} + +func TestBooleansAcceptTheirUsualForms(t *testing.T) { + trueForms := []string{"1", "t", "T", "true", "TRUE", "True"} + falseForms := []string{"0", "f", "F", "false", "FALSE", "False"} + + for _, form := range trueForms { + t.Run("EDUCATES_DEVELOPMENT_ENABLED="+form, func(t *testing.T) { + env := clitest.New(t) + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", form) + assert.Contains(t, configView(t, env), " enabled: true\n") + }) + } + for _, form := range falseForms { + t.Run("EDUCATES_DEVELOPMENT_ENABLED="+form, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: true\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", form) + assert.Contains(t, configView(t, env), " enabled: false\n") + }) + } + + fileForms := []struct { + value string + want string + }{ + {"true", "true"}, + {"True", "true"}, + {"TRUE", "true"}, + {"1", "true"}, + {"2", "true"}, + {`"t"`, "true"}, + {`"true"`, "true"}, + {"false", "false"}, + {"False", "false"}, + {"FALSE", "false"}, + {"0", "false"}, + {`"f"`, "false"}, + {`"false"`, "false"}, + {"", "false"}, + } + for _, tt := range fileForms { + t.Run("enabled: "+tt.value, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: " + tt.value + "\n") + assert.Contains(t, configView(t, env), " enabled: "+tt.want+"\n") + }) + } +} + +func TestBooleansInTheirUsualFormsGiveNoWarning(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: True\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", "1") + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: true\n") + assert.Empty(t, res.Stderr) +} + +func TestABooleanInTheEnvironmentInAnotherFormIsReadAsFalseWithAWarning(t *testing.T) { + for _, value := range []string{"yes", "on", "y", "2", " true"} { + t.Run(value, func(t *testing.T) { + env := clitest.New(t) + env.WriteConfig("development:\n enabled: true\n") + env.Setenv("EDUCATES_DEVELOPMENT_ENABLED", value) + warning := `Warning: EDUCATES_DEVELOPMENT_ENABLED is "` + value + `", which is not a boolean, so it is read as false. Set it to true or false.` + "\n" + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Equal(t, warning, res.Stderr) + + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: false\n") + assert.Equal(t, warning, res.Stderr) + }) + } +} + +func TestABooleanInTheConfigFileInAnotherFormIsReadAsFalseWithAWarning(t *testing.T) { + tests := []struct { + value string + shown string + }{ + {"yes", `is "yes", which is not a boolean`}, + {"on", `is "on", which is not a boolean`}, + {"Y", `is "Y", which is not a boolean`}, + {`"yes"`, `is "yes", which is not a boolean`}, + {`""`, `is "", which is not a boolean`}, + {"2026-10-26", `is "2026-10-26", which is not a boolean`}, + {"[true]", "is not a single value"}, + {"{on: true}", "is not a single value"}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("development:\n enabled: " + tt.value + "\n") + warning := "Warning: development.enabled in " + configPath + " " + tt.shown + ", so it is read as false. Set it to true or false.\n" + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Equal(t, warning, res.Stderr) + + res = env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " enabled: false\n") + assert.Equal(t, warning, res.Stderr) + }) + } +} + +func TestAMalformedValueIsAnError(t *testing.T) { + t.Run("a list in the config file", func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: [acme, widgets]\n") + + res := env.Run("config", "view") + + require.EqualError(t, res.Err, `github.org in `+configPath+`: expected a single value`) + }) + + t.Run("YAML that does not parse", func(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n bad indentation\n") + + res := env.Run("config", "view") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "error reading config file "+configPath+": ") + }) +} + +func TestConfigInitCreatesTheSameConfigAsInit(t *testing.T) { + env := clitest.New(t) + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + + res := env.Run("config", "init") + + require.NoError(t, res.Err) + assert.Equal(t, "Config file created at "+configPath+"\n", res.Stdout) + assertDefaultConfig(t, env) +} + +func TestConfigInitRefusesToReplaceAnExistingConfig(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + + res := env.Run("config", "init") + + require.EqualError(t, res.Err, "config file already exists at "+configPath) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, "github:\n org: acme\n", string(content)) +} diff --git a/pkg/cmd/development_test.go b/pkg/cmd/development_test.go new file mode 100644 index 0000000..a60e563 --- /dev/null +++ b/pkg/cmd/development_test.go @@ -0,0 +1,151 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// writeDevelopmentBinary writes an executable outside the bin directory, +// standing in for a locally built educates. +func writeDevelopmentBinary(t *testing.T, env *clitest.Env) string { + t.Helper() + path := filepath.Join(env.Home, "src", "educates", "educates") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("educates develop"), 0o755)) + return path +} + +// linkDevelopmentBinary writes a development binary and points the +// educates symlink at it, and returns the binary's path. +func linkDevelopmentBinary(t *testing.T, env *clitest.Env) string { + t.Helper() + devBinary := writeDevelopmentBinary(t, env) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + return devBinary +} + +func TestCommandsThatDealWithTheActiveBinaryRemoveTheDevelopmentSymlink(t *testing.T) { + tests := [][]string{ + {"list"}, + {"list-local"}, + {"use", "3.8.0"}, + {"install", "3.8.0"}, + {"init"}, + } + + for _, command := range tests { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + devBinary := linkDevelopmentBinary(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Warning: development mode is disabled; removed symlink to development binary.") + target, _ := os.Readlink(env.ActiveLink()) + assert.NotEqual(t, devBinary, target) + assert.FileExists(t, devBinary) + }) + } +} + +func TestOtherCommandsKeepTheDevelopmentSymlink(t *testing.T) { + tests := [][]string{ + {"version"}, + {"config", "view"}, + {"config", "init"}, + {"list-remote"}, + } + + for _, command := range tests { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + devBinary := linkDevelopmentBinary(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.NotContains(t, res.Stdout, "development mode is disabled") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) + }) + } +} + +func TestDisabledDevelopmentModeRemovesTheDevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, + "Warning: development mode is disabled; removed symlink to development binary. Please use 'educatesenv use ' to select a version\n"+ + "Installed versions:\nNo versions installed\n", + res.Stdout) + assert.NoFileExists(t, env.ActiveLink()) + assert.FileExists(t, devBinary) +} + +func TestASymlinkIntoASiblingOfTheBinDirectoryCountsAsADevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + sibling := env.BinDir() + "2" + siblingBinary := filepath.Join(sibling, "educates-3.8.0") + require.NoError(t, os.MkdirAll(sibling, 0o755)) + require.NoError(t, os.WriteFile(siblingBinary, []byte("educates 3.8.0"), 0o755)) + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "bin2", "educates-3.8.0"), env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, + "Warning: development mode is disabled; removed symlink to development binary. Please use 'educatesenv use ' to select a version\n"+ + "Installed versions:\nNo versions installed\n", + res.Stdout) + assert.NoFileExists(t, env.ActiveLink()) + assert.FileExists(t, siblingBinary) +} + +func TestABinDirectoryWithATrailingSlashKeepsItsVersionSymlink(t *testing.T) { + env := clitest.New(t) + env.Setenv("EDUCATES_LOCAL_DIR", env.BinDir()+string(filepath.Separator)) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* 3.8.0 (active)\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestEnabledDevelopmentModeKeepsTheDevelopmentSymlink(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* develop (active) -> "+devBinary+"\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) +} diff --git a/pkg/cmd/github_errors_test.go b/pkg/cmd/github_errors_test.go new file mode 100644 index 0000000..7789bec --- /dev/null +++ b/pkg/cmd/github_errors_test.go @@ -0,0 +1,106 @@ +package cmd_test + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// The API routes the commands call first. +const ( + releasesRoute = "GET /repos/educates/educates-training-platform/releases" + releaseByTagRoute = "GET /repos/educates/educates-training-platform/releases/tags/3.8.0" +) + +// githubCall is a command and the API route it calls first. +type githubCall struct { + args []string + route string +} + +// githubCalls are the commands that call GitHub, each with the route a +// failing response is served on. +var githubCalls = []githubCall{ + {args: []string{"list-remote"}, route: releasesRoute}, + {args: []string{"install", "3.8.0"}, route: releaseByTagRoute}, + {args: []string{"install", "latest"}, route: releasesRoute}, + {args: []string{"init", "--download"}, route: releasesRoute}, +} + +// rateLimitReset is when the rate limit the fake server reports resets. +var rateLimitReset = time.Date(2030, time.January, 2, 15, 4, 5, 0, time.UTC) + +// rateLimited answers as GitHub does once the rate limit is used up. Only +// this response carries rate limit headers, since a client that saw no +// remaining requests refuses to make more. +func rateLimited(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-RateLimit-Limit", "60") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(rateLimitReset.Unix(), 10)) + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"message":"API rate limit exceeded for 127.0.0.1."}`) +} + +// badCredentials answers as GitHub does when it rejects the token. +func badCredentials(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"message":"Bad credentials"}`) +} + +func TestARateLimitSaysWhenItResetsAndHowToAuthenticate(t *testing.T) { + for _, call := range githubCalls { + t.Run(strings.Join(call.args, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.Handle(call.route, rateLimited) + + res := env.Run(call.args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "GitHub API rate limit exceeded; it resets at 2030-01-02 15:04:05 UTC. "+ + "Set GH_TOKEN or run 'gh auth login' to authenticate and get a higher limit") + }) + } +} + +func TestARateLimitOnATokenNamesTheTokensSource(t *testing.T) { + env := clitest.New(t) + env.Setenv("GH_TOKEN", ghEnvToken) + env.GitHub.Handle(releasesRoute, rateLimited) + + res := env.Run("list-remote") + + require.EqualError(t, res.Err, "GitHub API rate limit exceeded for the token from GH_TOKEN; it resets at 2030-01-02 15:04:05 UTC") +} + +func TestARejectedTokenNamesItsSource(t *testing.T) { + for _, source := range tokenSources { + for _, call := range githubCalls { + t.Run(source.name+"/"+strings.Join(call.args, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + source.set(env) + env.GitHub.Handle(call.route, badCredentials) + + res := env.Run(call.args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "GitHub rejected the token from "+source.name+": 401 Bad credentials. "+ + "Replace or remove that token and try again") + assert.NotContains(t, res.Err.Error(), source.token) + assert.NotContains(t, res.Stdout, source.token) + assert.NotContains(t, res.Stderr, source.token) + }) + } + } +} diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 1dbfeec..d780ae9 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -2,73 +2,87 @@ package cmd import ( "fmt" + "io" + "os" + "path/filepath" "runtime" "github.com/spf13/cobra" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/platform" + "github.com/educates/educatesenv/pkg/version" ) -var ( - downloadLatest bool - overwrite bool -) +func newInitCommand(d *deps) *cobra.Command { + var ( + downloadLatest bool + overwrite bool + ) -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize educatesenv: create default config and folders, and show PATH instructions", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - _, defaultBin, configPath, configCreated, err := config.CreateConfigAndFolders() - if err != nil { - return err - } + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize educatesenv: create default config and folders, and show PATH instructions", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() - if configCreated { - fmt.Printf("Config file created at %s\n", configPath) - } else { - fmt.Printf("Config file already exists at %s\n", configPath) - } - fmt.Printf("Bin directory ensured at %s\n", defaultBin) + paths, configCreated, err := config.CreateDefault() + if err != nil { + return err + } + defaultBin := paths.BinDir - // Print PATH instructions - fmt.Println("\nTo use educatesenv, add the bin directory to your PATH:") - var pathCmd string - switch runtime.GOOS { - case platform.Darwin, platform.Linux: - pathCmd = fmt.Sprintf("echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc", defaultBin) - fmt.Printf("\nFor bash/zsh, run:\n %s\n", pathCmd) - fmt.Printf("\nFor fish shell, run:\n set -U fish_user_paths %s $fish_user_paths\n", defaultBin) - case platform.Windows: - fmt.Printf("\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) - default: - fmt.Printf("\nAdd %s to your PATH manually.\n", defaultBin) - } - fmt.Println("\nRestart your terminal or source your profile to apply the changes.") + if configCreated { + _, _ = fmt.Fprintf(out, "Config file created at %s\n", paths.ConfigFile) + } else { + _, _ = fmt.Fprintf(out, "Config file already exists at %s\n", paths.ConfigFile) + } + _, _ = fmt.Fprintf(out, "Bin directory ensured at %s\n", defaultBin) - if downloadLatest { - fmt.Println("\nFetching latest educates version...") - latest, err := gh.GetLatestReleaseVersion() - if err != nil { - return fmt.Errorf("failed to get latest release version: %w", err) + // Print PATH instructions + _, _ = fmt.Fprintln(out, "\nTo use educatesenv, add the bin directory to your PATH:") + switch runtime.GOOS { + case platform.Windows: + _, _ = fmt.Fprintf(out, "\nFor Windows (PowerShell), run:\n [Environment]::SetEnvironmentVariable(\"Path\", \"%s;\" + $Env:Path, [EnvironmentVariableTarget]::User)\n", defaultBin) + default: + writeShellPathInstructions(out, os.Getenv("SHELL"), defaultBin) } - fmt.Printf("Latest version: %s\n", latest) + _, _ = fmt.Fprintln(out, "\nRestart your terminal or source your profile to apply the changes.") - if err := manager.InstallVersion(latest, overwrite, true); err != nil { - return err + if downloadLatest { + _, _ = fmt.Fprintln(out, "\nFetching latest educates version...") + if err := d.manager.InstallVersion(version.Latest, overwrite, true); err != nil { + return err + } + } else { + _, _ = fmt.Fprintln(out, "\nRun 'educatesenv install ' to install a specific version") + _, _ = fmt.Fprintln(out, "Run 'educatesenv list-remote' to see available versions") } - } else { - fmt.Println("\nRun 'educatesenv install ' to install a specific version") - fmt.Println("Run 'educatesenv list-remote' to see available versions") - } - return nil - }, + return nil + }, + } + + cmd.Flags().BoolVar(&downloadLatest, "download", false, "Download and set as active the latest stable version") + cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Force download even if the version already exists") + return cmd } -func init() { - initCmd.Flags().BoolVar(&downloadLatest, "download", false, "Download and set as active the latest stable version") - initCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Force download even if the version already exists") - rootCmd.AddCommand(initCmd) +// writeShellPathInstructions writes how to add binDir to the PATH in the +// shell at the path shell, as $SHELL gives it: zsh and bash get a line for +// their rc file, fish gets fish_user_paths, and any other shell gets +// generic instructions. +func writeShellPathInstructions(out io.Writer, shell, binDir string) { + switch name := filepath.Base(shell); name { + case "zsh", "bash": + rcFile := "~/." + name + "rc" + _, _ = fmt.Fprintf(out, "\nFor %s, run:\n echo 'export PATH=\"%s:$PATH\"' >> %s\n source %s\n", name, binDir, rcFile, rcFile) + case "fish": + _, _ = fmt.Fprintf(out, "\nFor fish, run:\n set -U fish_user_paths %s $fish_user_paths\n", binDir) + default: + _, _ = fmt.Fprintf(out, "\nAdd %s to your PATH in your shell's startup file.\n", binDir) + } } diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go new file mode 100644 index 0000000..1e176c7 --- /dev/null +++ b/pkg/cmd/init_test.go @@ -0,0 +1,179 @@ +package cmd_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +// defaultConfig returns the config file that init and config init create. +func defaultConfig(env *clitest.Env) string { + return "github:\n" + + " org: educates\n" + + " repository: educates-training-platform\n" + + "local:\n" + + " dir: " + env.BinDir() + "\n" + + "development:\n" + + " enabled: false\n" + + " binaryLocation: \"\"\n" +} + +// assertDefaultConfig checks that the config file holds the default config +// and that only its owner can read it. +func assertDefaultConfig(t *testing.T, env *clitest.Env) { + t.Helper() + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + info, err := os.Stat(configPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, defaultConfig(env), string(content)) +} + +func TestInitWritesTheDefaultConfigReadableOnlyByItsOwner(t *testing.T) { + env := clitest.New(t) + + res := env.Run("init") + + require.NoError(t, res.Err) + assertDefaultConfig(t, env) +} + +// pathInstructions returns the part of init's output that says how to add +// the bin directory to the PATH. +func pathInstructions(t *testing.T, stdout string) string { + t.Helper() + _, rest, found := strings.Cut(stdout, "To use educatesenv, add the bin directory to your PATH:\n") + require.True(t, found, stdout) + instructions, _, found := strings.Cut(rest, "\nRestart your terminal or source your profile to apply the changes.\n") + require.True(t, found, stdout) + return instructions +} + +func TestInitGivesPathInstructionsForTheShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows gets PowerShell instructions") + } + + tests := []struct { + name string + shell string + want func(bin string) string + }{ + {name: "zsh", shell: "/bin/zsh", want: func(bin string) string { + return "\nFor zsh, run:\n echo 'export PATH=\"" + bin + ":$PATH\"' >> ~/.zshrc\n source ~/.zshrc\n" + }}, + {name: "bash", shell: "/opt/homebrew/bin/bash", want: func(bin string) string { + return "\nFor bash, run:\n echo 'export PATH=\"" + bin + ":$PATH\"' >> ~/.bashrc\n source ~/.bashrc\n" + }}, + {name: "fish", shell: "/usr/local/bin/fish", want: func(bin string) string { + return "\nFor fish, run:\n set -U fish_user_paths " + bin + " $fish_user_paths\n" + }}, + {name: "another shell", shell: "/bin/sh", want: func(bin string) string { + return "\nAdd " + bin + " to your PATH in your shell's startup file.\n" + }}, + {name: "SHELL not set", want: func(bin string) string { + return "\nAdd " + bin + " to your PATH in your shell's startup file.\n" + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + if tt.shell != "" { + env.Setenv("SHELL", tt.shell) + } + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want(env.BinDir()), pathInstructions(t, res.Stdout)) + }) + } +} + +func TestCreatingTheConfigWithoutAHomeDirectoryFails(t *testing.T) { + for _, args := range [][]string{{"init"}, {"config", "init"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + env := clitest.New(t) + env.Unsetenv("HOME") + + res := env.Run(args...) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "could not determine home directory") + assert.NoDirExists(t, filepath.Join(env.WorkDir, ".educatesenv")) + }) + } +} + +func TestInitCreatesTheConfigAndBinDirectory(t *testing.T) { + env := clitest.New(t) + configPath := filepath.Join(env.ConfigDir(), "config.yaml") + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Config file created at "+configPath+"\n") + assert.Contains(t, res.Stdout, "Bin directory ensured at "+env.BinDir()+"\n") + assert.Contains(t, res.Stdout, "Run 'educatesenv list-remote' to see available versions\n") + assert.Empty(t, res.Stderr) + assert.FileExists(t, configPath) + assert.DirExists(t, env.BinDir()) +} + +func TestInitKeepsAnExistingConfig(t *testing.T) { + env := clitest.New(t) + configPath := env.WriteConfig("github:\n org: acme\n") + + res := env.Run("init") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Config file already exists at "+configPath+"\n") + content, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, "github:\n org: acme\n", string(content)) +} + +func TestInitDownloadInstallsTheHighestStableReleaseAfterARunOfPreReleases(t *testing.T) { + env := clitest.New(t) + // The twelve newest releases are release candidates of 3.8.0, as + // during its release-candidate cycle. + for rc := 12; rc >= 1; rc-- { + addPublishedRelease(env, fmt.Sprintf("3.8.0-rc.%d", rc), published(time.August, 14+rc), true) + } + addPublishedRelease(env, "3.7.3", published(time.August, 1), false) + addPublishedRelease(env, "3.7.2", published(time.July, 1), false) + + res := env.Run("init", "--download") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.7.3\nInstalling version 3.7.3...\n") + assert.Contains(t, res.Stdout, "educates 3.7.3 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.7.3", target) + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.7.3")) + require.NoError(t, err) + assert.Equal(t, "educates 3.7.3 binary", string(content)) +} + +func TestInitDownloadFailsWhenThereIsNoStableRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.8.0-rc.1", published(time.August, 14), true) + + res := env.Run("init", "--download") + + require.EqualError(t, res.Err, noStableRelease) + assert.NoFileExists(t, env.ActiveLink()) +} diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 4cc7629..192beb7 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -9,34 +9,38 @@ import ( "github.com/educates/educatesenv/pkg/platform" ) -var ( - useAfterInstall bool - forceOverwrite bool -) - -var installCmd = &cobra.Command{ - Use: "install ", - Short: "Install a specific version of educates", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] - - if !platform.IsSupportedPlatform(runtime.GOOS, runtime.GOARCH) { - return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) - } - - if err := manager.InstallVersion(version, forceOverwrite, useAfterInstall); err != nil { - return fmt.Errorf("failed to install version %s: %w", version, err) - } - - return nil - }, -} - -func init() { - installCmd.Flags().BoolVar(&useAfterInstall, "use", false, "Set the installed version as active") - installCmd.Flags().BoolVar(&forceOverwrite, "overwrite", false, "Force download even if the version already exists") - rootCmd.AddCommand(installCmd) +func newInstallCommand(d *deps) *cobra.Command { + var ( + useAfterInstall bool + forceOverwrite bool + ) + + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a specific version of educates, or the latest stable one", + Long: `Install a version of educates into the bin directory. The version +latest installs the highest stable release by semver, and says which +version that is.`, + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, + RunE: func(cmd *cobra.Command, args []string) error { + version := args[0] + + if !platform.IsSupportedPlatform(runtime.GOOS, runtime.GOARCH) { + return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + } + + if err := d.manager.InstallVersion(version, forceOverwrite, useAfterInstall); err != nil { + return fmt.Errorf("failed to install version %s: %w", version, err) + } + + return nil + }, + } + + cmd.Flags().BoolVar(&useAfterInstall, "use", false, "Set the installed version as active") + cmd.Flags().BoolVar(&forceOverwrite, "overwrite", false, "Force download even if the version already exists") + return cmd } diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go new file mode 100644 index 0000000..b009c18 --- /dev/null +++ b/pkg/cmd/install_test.go @@ -0,0 +1,353 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +// addRelease adds a release with the platform binary and a checksums.txt +// that lists it, as every Educates release has. +func addRelease(env *clitest.Env, tag string, binary []byte) { + asset := clitest.PlatformBinary(binary) + env.GitHub.AddReleases(fakegithub.Release{ + Tag: tag, + Assets: []fakegithub.Asset{asset, fakegithub.Checksums(asset)}, + }) +} + +func TestInstallDownloadsANamedVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Installing version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Empty(t, res.Stderr) + binary := filepath.Join(env.BinDir(), "educates-3.8.0") + content, err := os.ReadFile(binary) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + info, err := os.Stat(binary) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.NoFileExists(t, env.ActiveLink()) +} + +func TestInstallWithUseActivatesTheVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "3.8.0", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestInstallOfAnUnknownVersionFails(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "9.9.9") + + require.EqualError(t, res.Err, "failed to install version 9.9.9: version 9.9.9 not found. Run 'educatesenv list-remote' to see available versions") + assert.NoFileExists(t, filepath.Join(env.BinDir(), "educates-9.9.9")) +} + +// binDirEntries returns the names of the files in the bin directory. +func binDirEntries(t *testing.T, env *clitest.Env) []string { + t.Helper() + entries, err := os.ReadDir(env.BinDir()) + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +func TestInstallCutMidTransferLeavesNothingBehindAndARetryInstalls(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.CutNextDownload("3.8.0", clitest.PlatformBinaryName()) + + res := env.Run("install", "3.8.0") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: failed to download binary") + assert.Empty(t, binDirEntries(t, env)) + + res = env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Installing version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallFailsWhenTheDownloadStallsAndLeavesNothingBehindAndARetryInstalls(t *testing.T) { + env := clitest.New(t) + env.DownloadIdleTimeout = 100 * time.Millisecond + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + env.GitHub.StallNextDownload("3.8.0", clitest.PlatformBinaryName()) + + res := env.Run("install", "3.8.0") + + require.EqualError(t, res.Err, "failed to install version 3.8.0: failed to download binary (check your internet connection and try again): download stalled: no data arrived for 0.1 seconds") + assert.NotContains(t, res.Stdout, "installed successfully") + assert.Empty(t, binDirEntries(t, env)) + + res = env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallFinishesASlowDownloadThatKeepsMakingProgress(t *testing.T) { + env := clitest.New(t) + env.DownloadIdleTimeout = 250 * time.Millisecond + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + // The binary arrives in 8 chunks 50 milliseconds apart: no wait comes + // near the idle timeout, but the transfer takes longer than it in all. + env.GitHub.TrickleDownloads("3.8.0", clitest.PlatformBinaryName(), 8, 50*time.Millisecond) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +// releasedSum is the SHA-256 checksum of "educates 3.8.0 binary". +const releasedSum = "21163080cb987ff30bc789604b421676c7065e47b4c77415af76459db9bfc4bd" + +// addTamperedRelease adds a release whose binary is not the one its +// checksums.txt lists. +func addTamperedRelease(env *clitest.Env, tag string) { + env.GitHub.AddReleases(fakegithub.Release{Tag: tag, Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("tampered binary")), + {Name: "checksums.txt", Content: []byte(releasedSum + " " + clitest.PlatformBinaryName() + "\n")}, + }}) +} + +func TestInstallVerifiesTheBinaryAgainstChecksumsTxt(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + {Name: "checksums.txt", Content: []byte( + "5e990e9569bdb6a9a588600567e649ae1a63aa23b387ef9010b05dafada7657c educates-plan9-mips\n" + + releasedSum + " " + clitest.PlatformBinaryName() + "\n" + + "2d02d0c7095de4b2570439088c502b5a3fb02a8c9aee29844bd9878894db85c9 educates-installer-app.yaml\n")}, + }}) + + res := env.Run("install", "3.8.0", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Checksum verified against checksums.txt.\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + assert.Empty(t, res.Stderr) + binary := filepath.Join(env.BinDir(), "educates-3.8.0") + content, err := os.ReadFile(binary) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + info, err := os.Stat(binary) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.ElementsMatch(t, []string{"educates", "educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallFailsOnAChecksumMismatchAndLeavesNothingBehind(t *testing.T) { + env := clitest.New(t) + addTamperedRelease(env, "3.8.0") + + res := env.Run("install", "3.8.0", "--use") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: checksum mismatch for "+clitest.PlatformBinaryName()) + assert.NotContains(t, res.Stdout, "installed successfully") + assert.Empty(t, binDirEntries(t, env)) +} + +func TestInstallOverwriteWithAChecksumMismatchKeepsTheInstalledBinary(t *testing.T) { + env := clitest.New(t) + installed := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + addTamperedRelease(env, "3.8.0") + + res := env.Run("install", "3.8.0", "--overwrite") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "checksum mismatch for "+clitest.PlatformBinaryName()) + assert.Contains(t, res.Stdout, "Reinstalling version 3.8.0...\n") + content, err := os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0", string(content)) + info, err := os.Stat(installed) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + assert.ElementsMatch(t, []string{"educates", "educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallFailsWhenChecksumsTxtHasNoEntryForTheBinary(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + {Name: "checksums.txt", Content: []byte(releasedSum + " educates-plan9-mips\n")}, + }}) + + res := env.Run("install", "3.8.0") + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to install version 3.8.0: checksums.txt has no entry for "+clitest.PlatformBinaryName()) + assert.Empty(t, binDirEntries(t, env)) +} + +func TestInstallWarnsAndContinuesWhenTheReleaseHasNoChecksumsTxt(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0", Assets: []fakegithub.Asset{ + clitest.PlatformBinary([]byte("educates 3.8.0 binary")), + }}) + + res := env.Run("install", "3.8.0") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stderr, "Warning: release 3.8.0 has no checksums.txt") + assert.NotContains(t, res.Stdout, "Warning") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallLatestInstallsTheHighestStableRelease(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.9.0-rc.1", []byte("educates 3.9.0-rc.1 binary")) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + addRelease(env, "3.7.0", []byte("educates 3.7.0 binary")) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nInstalling version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + assert.Empty(t, res.Stderr) + content, err := os.ReadFile(filepath.Join(env.BinDir(), "educates-3.8.0")) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +func TestInstallLatestWithUseActivatesTheResolvedVersion(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + addRelease(env, "3.7.0", []byte("educates 3.7.0 binary")) + + res := env.Run("install", "latest", "--use") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 is now active.\n") + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestInstallLatestKeepsAnInstalledVersionUnlessOverwritten(t *testing.T) { + env := clitest.New(t) + installed := env.WriteBinary("3.8.0") + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nVersion 3.8.0 is already installed.\n") + content, err := os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0", string(content)) + + res = env.Run("install", "latest", "--overwrite") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\nReinstalling version 3.8.0...\n") + assert.Contains(t, res.Stdout, "educates 3.8.0 installed successfully.\n") + content, err = os.ReadFile(installed) + require.NoError(t, err) + assert.Equal(t, "educates 3.8.0 binary", string(content)) +} + +// addPublishedRelease adds a release published at the given time, with +// the platform binary and a checksums.txt that lists it. +func addPublishedRelease(env *clitest.Env, tag string, at time.Time, prerelease bool) { + asset := clitest.PlatformBinary([]byte("educates " + tag + " binary")) + env.GitHub.AddReleases(fakegithub.Release{ + Tag: tag, + PublishedAt: at, + Prerelease: prerelease, + Assets: []fakegithub.Asset{asset, fakegithub.Checksums(asset)}, + }) +} + +func TestInstallLatestNeverPicksABackportPublishedAfterAHigherRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.7.4", published(time.September, 10), false) + addPublishedRelease(env, "3.8.0", published(time.August, 26), false) + addPublishedRelease(env, "3.7.3", published(time.July, 1), false) + + res := env.Run("install", "latest") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Resolved latest to 3.8.0\n") + assert.Equal(t, []string{"educates-3.8.0"}, binDirEntries(t, env)) +} + +// noStableRelease is the error resolving latest gives when the repository +// has no stable release. +const noStableRelease = "no stable releases found in educates/educates-training-platform. Run 'educatesenv list-remote --pre-releases' to see pre-releases" + +func TestInstallLatestFailsWhenThereIsNoStableRelease(t *testing.T) { + env := clitest.New(t) + addPublishedRelease(env, "3.8.0-rc.2", published(time.August, 15), true) + addPublishedRelease(env, "3.8.0-rc.1", published(time.August, 14), true) + + res := env.Run("install", "latest") + + require.EqualError(t, res.Err, "failed to install version latest: "+noStableRelease) + assert.NotContains(t, res.Stdout, "Resolved latest") + assert.Empty(t, binDirEntries(t, env)) +} + +func TestInstallLatestNamesARepositoryThatIsNotFound(t *testing.T) { + env := clitest.New(t) + env.GitHub.SetRepository("acme", "elsewhere") + + res := env.Run("install", "latest") + + require.EqualError(t, res.Err, "failed to install version latest: repository educates/educates-training-platform not found") +} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 97d3444..c0b3764 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -2,81 +2,65 @@ package cmd import ( "fmt" - "os" - "path/filepath" - "strings" "github.com/educates/educatesenv/pkg/config" + "github.com/educates/educatesenv/pkg/releases" + "github.com/educates/educatesenv/pkg/version" "github.com/spf13/cobra" ) -var listCmd = &cobra.Command{ - Use: "list", - Short: "List installed educates versions", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Get all files in the bin directory - files, err := os.ReadDir(cfg.Local.Dir) - // If the bin directory doesn't exist call init +func newListCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List installed educates versions", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, + RunE: runList(d), + } +} + +// runList returns the run function that list and list-local share. +func runList(d *deps) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + cfg := d.cfg + out := cmd.OutOrStdout() + + versions, activeVersion, err := d.manager.Installed() + // If the bin directory cannot be read, create the default config + // and bin directory, as init does if err != nil { - _, _, _, _, err := config.CreateConfigAndFolders() - if err != nil { + if _, _, err := config.CreateDefault(); err != nil { return fmt.Errorf("you should run `educatesenv init` first") } } - // Get the active version by reading the symlink - activeVersion := "" - isDevelopmentActive := false - symlink := filepath.Join(cfg.Local.Dir, "educates") - if target, err := os.Readlink(symlink); err == nil { - // Check if the active version is a development version - if cfg.Development.Enabled && target == cfg.Development.BinaryLocation { - isDevelopmentActive = true - } else { - activeVersion = filepath.Base(target) - activeVersion = strings.TrimPrefix(activeVersion, "educates-") - } - } - // Print installed versions - fmt.Println("Installed versions:") + _, _ = fmt.Fprintln(out, "Installed versions:") // Show development version if enabled if cfg.Development.Enabled { - if isDevelopmentActive { - fmt.Printf("* develop (active) -> %s\n", cfg.Development.BinaryLocation) + if activeVersion == version.Develop { + _, _ = fmt.Fprintf(out, "* develop (active) -> %s\n", cfg.Development.BinaryLocation) } else { - fmt.Printf(" develop -> %s\n", cfg.Development.BinaryLocation) + _, _ = fmt.Fprintf(out, " develop -> %s\n", cfg.Development.BinaryLocation) } } - // Print regular versions - foundVersions := false - for _, file := range files { - if file.IsDir() || file.Name() == "educates" { - continue - } - if strings.HasPrefix(file.Name(), "educates-") { - version := strings.TrimPrefix(file.Name(), "educates-") - if version == activeVersion { - fmt.Printf("* %s (active)\n", version) - } else { - fmt.Printf(" %s\n", version) - } - foundVersions = true + // Print regular versions, newest first + for _, name := range releases.SortInstalled(versions) { + if name == activeVersion { + _, _ = fmt.Fprintf(out, "* %s (active)\n", name) + } else { + _, _ = fmt.Fprintf(out, " %s\n", name) } } - if !foundVersions && !cfg.Development.Enabled { - fmt.Println("No versions installed") + if len(versions) == 0 && !cfg.Development.Enabled { + _, _ = fmt.Fprintln(out, "No versions installed") } return nil - }, -} - -func init() { - rootCmd.AddCommand(listCmd) + } } diff --git a/pkg/cmd/list_local.go b/pkg/cmd/list_local.go index 0b421a4..53fbc3b 100644 --- a/pkg/cmd/list_local.go +++ b/pkg/cmd/list_local.go @@ -4,15 +4,15 @@ import ( "github.com/spf13/cobra" ) -var listLocalCmd = &cobra.Command{ - Use: "list-local", - Short: "List installed educates versions (alias for list)", - Hidden: true, - SilenceErrors: true, - SilenceUsage: true, - RunE: listCmd.RunE, -} - -func init() { - rootCmd.AddCommand(listLocalCmd) +func newListLocalCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "list-local", + Short: "List installed educates versions (alias for list)", + Hidden: true, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, + RunE: runList(d), + } } diff --git a/pkg/cmd/list_remote.go b/pkg/cmd/list_remote.go index 8fab81b..4067653 100644 --- a/pkg/cmd/list_remote.go +++ b/pkg/cmd/list_remote.go @@ -2,77 +2,89 @@ package cmd import ( "fmt" - "sort" - "strings" + "text/tabwriter" + "time" "github.com/spf13/cobra" -) -var ( - showAll bool - showRecents bool + "github.com/educates/educatesenv/pkg/releases" ) -// isPreRelease checks if a version string contains pre-release indicators -func isPreRelease(version string) bool { - preReleaseIndicators := []string{ - "-alpha", "-beta", "-rc", - ".alpha.", ".beta.", ".rc.", - } - version = strings.ToLower(version) - for _, indicator := range preReleaseIndicators { - if strings.Contains(version, indicator) { - return true - } - } - return false -} - -var listRemoteCmd = &cobra.Command{ - Use: "list-remote", - Short: "List all available versions from GitHub releases", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - releases, err := gh.ListReleases() - if err != nil { - return fmt.Errorf("failed to fetch releases: %w", err) - } +func newListRemoteCommand(d *deps) *cobra.Command { + var ( + recent int + all bool + preReleases bool + byDate bool + recents bool + ) - // Filter and collect versions - var versions []string - for _, rel := range releases { - version := rel.GetTagName() - if !showAll && (rel.GetPrerelease() || isPreRelease(version)) { - continue + cmd := &cobra.Command{ + Use: "list-remote", + Short: "List the newest versions available from GitHub releases", + Long: `List the versions available from GitHub releases, with the date each +was published. By default it shows the 10 newest stable releases, +highest version first.`, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if recents { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Flag --recents has been deprecated, the 10 most recent releases are now shown by default") + } + if recent < 1 { + return fmt.Errorf("--recent must be at least 1, got %d", recent) } - versions = append(versions, version) - } - - // Sort versions in reverse order (newest first) - sort.Sort(sort.Reverse(sort.StringSlice(versions))) - // Limit to recent versions if requested - if showRecents && len(versions) > 10 { - versions = versions[:10] - } + gh, err := d.githubClient() + if err != nil { + return err + } + published, err := gh.ListReleases() + if err != nil { + return err + } - // Print versions - fmt.Println("Available versions:") - for _, version := range versions { - fmt.Printf("- %s\n", version) - } + opts := releases.SelectOptions{IncludePrereleases: preReleases, Limit: recent} + if all { + opts.Limit = releases.All + } + if byDate { + opts.Order = releases.ByPublishTime + } + chosen, candidates := releases.Select(published, opts) + if len(chosen) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found") + return nil + } - if len(versions) == 0 { - fmt.Println("No versions found") - } + table := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(table, "VERSION\tRELEASED") + for _, r := range chosen { + _, _ = fmt.Fprintf(table, "%s\t%s\n", r.Tag, r.PublishedAt.UTC().Format(time.DateOnly)) + } + _ = table.Flush() - return nil - }, -} + if len(chosen) < candidates { + if preReleases { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Showing %d of %d releases. Use --all to see all.\n", len(chosen), candidates) + } else { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Showing %d of %d stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", len(chosen), candidates) + } + } + return nil + }, + } -func init() { - listRemoteCmd.Flags().BoolVar(&showAll, "all", false, "Show all versions including pre-releases") - listRemoteCmd.Flags().BoolVar(&showRecents, "recents", false, "Show only the 10 most recent versions") - rootCmd.AddCommand(listRemoteCmd) + cmd.Flags().IntVarP(&recent, "recent", "n", 10, "Show the N newest releases") + cmd.Flags().BoolVar(&all, "all", false, "Show every release instead of the newest") + cmd.Flags().BoolVar(&preReleases, "pre-releases", false, "Include pre-releases") + cmd.Flags().BoolVar(&byDate, "by-date", false, "Order by publish date instead of by version") + // --recents is deprecated. pflag's MarkDeprecated would print its + // warning through cobra's output writer, which is stdout once SetOut is + // called, so the flag is hidden and RunE prints the warning to stderr. + cmd.Flags().BoolVar(&recents, "recents", false, "Show only the 10 most recent versions") + _ = cmd.Flags().MarkHidden("recents") + cmd.MarkFlagsMutuallyExclusive("all", "recent") + return cmd } diff --git a/pkg/cmd/list_remote_test.go b/pkg/cmd/list_remote_test.go new file mode 100644 index 0000000..ddd4588 --- /dev/null +++ b/pkg/cmd/list_remote_test.go @@ -0,0 +1,389 @@ +package cmd_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +// published returns noon UTC on the given day of 2026. +func published(month time.Month, day int) time.Time { + return time.Date(2026, month, day, 12, 0, 0, 0, time.UTC) +} + +// addReleaseHistory adds twelve stable releases, 3.11.0 down to 3.0.0, +// published on the first of each month from December back to January, +// and two pre-releases of 4.0.0 published since. +func addReleaseHistory(env *clitest.Env) { + env.GitHub.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.13", PublishedAt: published(time.December, 20), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.2", PublishedAt: published(time.December, 10), Prerelease: true}, + fakegithub.Release{Tag: "3.11.0", PublishedAt: published(time.December, 1)}, + fakegithub.Release{Tag: "3.10.0", PublishedAt: published(time.November, 1)}, + fakegithub.Release{Tag: "3.9.0", PublishedAt: published(time.October, 1)}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: published(time.September, 1)}, + fakegithub.Release{Tag: "3.7.0", PublishedAt: published(time.August, 1)}, + fakegithub.Release{Tag: "3.6.0", PublishedAt: published(time.July, 1)}, + fakegithub.Release{Tag: "3.5.0", PublishedAt: published(time.June, 1)}, + fakegithub.Release{Tag: "3.4.0", PublishedAt: published(time.May, 1)}, + fakegithub.Release{Tag: "3.3.0", PublishedAt: published(time.April, 1)}, + fakegithub.Release{Tag: "3.2.0", PublishedAt: published(time.March, 1)}, + fakegithub.Release{Tag: "3.1.0", PublishedAt: published(time.February, 1)}, + fakegithub.Release{Tag: "3.0.0", PublishedAt: published(time.January, 1)}, + ) +} + +func TestListRemoteShowsTheTenNewestStableReleasesBySemver(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n", + res.Stdout) + assert.Equal(t, "Showing 10 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) +} + +func TestListRemoteRecentSetsHowManyReleasesAreShown(t *testing.T) { + forms := [][]string{ + {"--recent", "3"}, + {"--recent=3"}, + {"-n", "3"}, + {"-n3"}, + } + + for _, form := range forms { + t.Run(form[0], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, form...)...) + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n", + res.Stdout) + assert.Equal(t, "Showing 3 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) + }) + } +} + +func TestListRemoteAllShowsEveryStableRelease(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run("list-remote", "--all") + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n"+ + "3.1.0 2026-02-01\n"+ + "3.0.0 2026-01-01\n", + res.Stdout) + assert.Empty(t, res.Stderr) +} + +func TestListRemotePrereleasesAddsPrereleases(t *testing.T) { + tests := []struct { + name string + args []string + stdout string + stderr string + }{ + { + name: "the ten newest", + args: []string{"--pre-releases"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-12-20\n" + + "4.0.0-alpha.2 2026-12-10\n" + + "3.11.0 2026-12-01\n" + + "3.10.0 2026-11-01\n" + + "3.9.0 2026-10-01\n" + + "3.8.0 2026-09-01\n" + + "3.7.0 2026-08-01\n" + + "3.6.0 2026-07-01\n" + + "3.5.0 2026-06-01\n" + + "3.4.0 2026-05-01\n", + stderr: "Showing 10 of 14 releases. Use --all to see all.\n", + }, + { + name: "every release", + args: []string{"--pre-releases", "--all"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-12-20\n" + + "4.0.0-alpha.2 2026-12-10\n" + + "3.11.0 2026-12-01\n" + + "3.10.0 2026-11-01\n" + + "3.9.0 2026-10-01\n" + + "3.8.0 2026-09-01\n" + + "3.7.0 2026-08-01\n" + + "3.6.0 2026-07-01\n" + + "3.5.0 2026-06-01\n" + + "3.4.0 2026-05-01\n" + + "3.3.0 2026-04-01\n" + + "3.2.0 2026-03-01\n" + + "3.1.0 2026-02-01\n" + + "3.0.0 2026-01-01\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.NoError(t, res.Err) + assert.Equal(t, tt.stdout, res.Stdout) + assert.Equal(t, tt.stderr, res.Stderr) + }) + } +} + +func TestListRemoteByDateOrdersByPublishTime(t *testing.T) { + at := func(month time.Month, day, hour int) time.Time { + return time.Date(2026, month, day, hour, 0, 0, 0, time.UTC) + } + + tests := []struct { + name string + args []string + stdout string + stderr string + }{ + { + name: "with pre-releases", + args: []string{"--by-date", "--pre-releases"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-09-03\n" + + "3.8.1-rc.2 2026-09-01\n" + + "4.0.0-alpha.12 2026-08-27\n" + + "3.8.0 2026-08-26\n" + + "3.8.0-rc.13 2026-08-25\n" + + "3.8.0-rc.12 2026-08-25\n" + + "4.0.0-alpha.11 2026-08-20\n", + }, + { + name: "with pre-releases and a limit", + args: []string{"--by-date", "--pre-releases", "-n", "3"}, + stdout: "" + + "VERSION RELEASED\n" + + "4.0.0-alpha.13 2026-09-03\n" + + "3.8.1-rc.2 2026-09-01\n" + + "4.0.0-alpha.12 2026-08-27\n", + stderr: "Showing 3 of 7 releases. Use --all to see all.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases( + fakegithub.Release{Tag: "4.0.0-alpha.13", PublishedAt: at(time.September, 3, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.1-rc.2", PublishedAt: at(time.September, 1, 9), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.12", PublishedAt: at(time.August, 27, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.0", PublishedAt: at(time.August, 26, 18)}, + fakegithub.Release{Tag: "3.8.0-rc.12", PublishedAt: at(time.August, 25, 9), Prerelease: true}, + fakegithub.Release{Tag: "3.8.0-rc.13", PublishedAt: at(time.August, 25, 17), Prerelease: true}, + fakegithub.Release{Tag: "4.0.0-alpha.11", PublishedAt: at(time.August, 20, 9), Prerelease: true}, + ) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.NoError(t, res.Err) + assert.Equal(t, tt.stdout, res.Stdout) + assert.Equal(t, tt.stderr, res.Stderr) + }) + } +} + +func TestListRemoteRecentsIsDeprecated(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run("list-remote", "--recents") + + require.NoError(t, res.Err) + assert.Equal(t, ""+ + "VERSION RELEASED\n"+ + "3.11.0 2026-12-01\n"+ + "3.10.0 2026-11-01\n"+ + "3.9.0 2026-10-01\n"+ + "3.8.0 2026-09-01\n"+ + "3.7.0 2026-08-01\n"+ + "3.6.0 2026-07-01\n"+ + "3.5.0 2026-06-01\n"+ + "3.4.0 2026-05-01\n"+ + "3.3.0 2026-04-01\n"+ + "3.2.0 2026-03-01\n", + res.Stdout) + assert.Equal(t, ""+ + "Flag --recents has been deprecated, the 10 most recent releases are now shown by default\n"+ + "Showing 10 of 12 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", + res.Stderr) +} + +func TestListRemoteHelpLeavesOutRecents(t *testing.T) { + env := clitest.New(t) + + res := env.Run("list-remote", "--help") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "--recent int") + assert.NotContains(t, res.Stdout, "--recents") +} + +func TestListRemoteConsidersReleasesOnEveryPage(t *testing.T) { + env := clitest.New(t) + // 250 releases, 2.249.0 down to 2.0.0, span three pages of 100. + first := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + for i := 249; i >= 0; i-- { + env.GitHub.AddReleases(fakegithub.Release{ + Tag: fmt.Sprintf("2.%d.0", i), + PublishedAt: first.AddDate(0, 0, i), + }) + } + + res := env.Run("list-remote", "--all") + + require.NoError(t, res.Err) + lines := strings.Split(strings.TrimSuffix(res.Stdout, "\n"), "\n") + require.Len(t, lines, 251) + assert.Equal(t, "VERSION RELEASED", lines[0]) + assert.Equal(t, "2.249.0 2020-09-06", lines[1]) + assert.Equal(t, "2.0.0 2020-01-01", lines[250]) + + res = env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "Showing 10 of 250 stable releases. Use --all to see all, --pre-releases to include pre-releases.\n", res.Stderr) + + for _, req := range env.GitHub.Requests() { + assert.Equal(t, "100", req.Query.Get("per_page"), "per_page of %s?%s", req.Path, req.Query.Encode()) + } +} + +func TestListRemoteShowsPublishDatesInUTC(t *testing.T) { + env := clitest.New(t) + env.GitHub.Handle("GET /repos/educates/educates-training-platform/releases", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `[{"id":1,"tag_name":"3.8.0","draft":false,"prerelease":false,"published_at":"2026-08-27T01:30:00+02:00"}]`) + }) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "VERSION RELEASED\n3.8.0 2026-08-26\n", res.Stdout) +} + +func TestListRemoteSaysWhenNoVersionsAreFound(t *testing.T) { + tests := []struct { + name string + releases []fakegithub.Release + }{ + {name: "no releases"}, + { + name: "only drafts and pre-releases", + releases: []fakegithub.Release{ + {Tag: "3.9.0", Draft: true}, + {Tag: "3.9.0-rc.1", PublishedAt: published(time.August, 1), Prerelease: true}, + {Tag: "2.6.0-alpha.30", PublishedAt: published(time.July, 1)}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(tt.releases...) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "No versions found\n", res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListRemoteRejectsAllTogetherWithRecent(t *testing.T) { + forms := [][]string{ + {"--all", "--recent", "5"}, + {"--recent=3", "--all"}, + {"--all", "-n", "10"}, + } + + for _, form := range forms { + t.Run(form[0]+" "+form[1], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, form...)...) + + require.EqualError(t, res.Err, "if any flags in the group [all recent] are set none of the others can be; [all recent] were all set") + assert.Empty(t, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListRemoteRejectsRecentBelowOne(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {args: []string{"--recent", "0"}, want: "--recent must be at least 1, got 0"}, + {args: []string{"--recent=-5"}, want: "--recent must be at least 1, got -5"}, + {args: []string{"-n", "-1"}, want: "--recent must be at least 1, got -1"}, + } + + for _, tt := range tests { + t.Run(tt.args[0], func(t *testing.T) { + env := clitest.New(t) + addReleaseHistory(env) + + res := env.Run(append([]string{"list-remote"}, tt.args...)...) + + require.EqualError(t, res.Err, tt.want) + assert.Empty(t, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go new file mode 100644 index 0000000..0b57ac4 --- /dev/null +++ b/pkg/cmd/list_test.go @@ -0,0 +1,158 @@ +package cmd_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestListMarksTheActiveVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* 3.8.0 (active)\n", res.Stdout) + assert.Empty(t, res.Stderr) +} + +func TestListShowsInstalledVersionsNewestFirstBySemver(t *testing.T) { + tests := []struct { + name string + active string + want string + }{ + { + name: "no version active", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " 3.8\n" + + " my-build\n", + }, + { + name: "a semver version active", + active: "3.9.0", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + "* 3.9.0 (active)\n" + + " 3.8\n" + + " my-build\n", + }, + { + name: "a name that is not semver active", + active: "my-build", + want: "Installed versions:\n" + + " 4.0.0-alpha.13\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " 3.8\n" + + "* my-build (active)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + for _, v := range []string{"3.9.0", "my-build", "4.0.0-alpha.2", "3.8", "3.10.0", "4.0.0-alpha.13"} { + env.WriteBinary(v) + } + if tt.active != "" { + require.NoError(t, os.Symlink("educates-"+tt.active, env.ActiveLink())) + } + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want, res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListShowsDevelopFirstWhenDevelopmentModeIsEnabled(t *testing.T) { + tests := []struct { + name string + activeVersion string + want func(devBinary string) string + }{ + { + name: "develop active", + want: func(devBinary string) string { + return "Installed versions:\n" + + "* develop (active) -> " + devBinary + "\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + " 3.9.0\n" + + " my-build\n" + }, + }, + { + name: "an installed version active", + activeVersion: "3.9.0", + want: func(devBinary string) string { + return "Installed versions:\n" + + " develop -> " + devBinary + "\n" + + " 4.0.0-alpha.2\n" + + " 3.10.0\n" + + "* 3.9.0 (active)\n" + + " my-build\n" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + for _, v := range []string{"3.9.0", "my-build", "4.0.0-alpha.2", "3.10.0"} { + env.WriteBinary(v) + } + target := devBinary + if tt.activeVersion != "" { + target = "educates-" + tt.activeVersion + } + require.NoError(t, os.Symlink(target, env.ActiveLink())) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, tt.want(devBinary), res.Stdout) + assert.Empty(t, res.Stderr) + }) + } +} + +func TestListMarksDevelopActiveAfterUseDevelop(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + env.WriteBinary("3.8.0") + require.NoError(t, env.Run("use", "develop").Err) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\n* develop (active) -> "+devBinary+"\n 3.8.0\n", res.Stdout) +} + +func TestListSaysWhenNoVersionIsInstalled(t *testing.T) { + env := clitest.New(t) + + res := env.Run("list") + + require.NoError(t, res.Err) + assert.Equal(t, "Installed versions:\nNo versions installed\n", res.Stdout) +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 055ef85..4a511e7 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -2,57 +2,133 @@ package cmd import ( "fmt" + "io" + "time" "github.com/spf13/cobra" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/github" + "github.com/educates/educatesenv/pkg/token" "github.com/educates/educatesenv/pkg/version" ) -var ( +// Option changes how NewRootCommand builds the command tree. +type Option func(*options) + +type options struct { + githubBaseURL string + downloadIdleTimeout time.Duration +} + +// WithGitHubBaseURL points the GitHub client at baseURL instead of +// github.com's API. +func WithGitHubBaseURL(baseURL string) Option { + return func(o *options) { + o.githubBaseURL = baseURL + } +} + +// WithDownloadIdleTimeout makes a download fail when no data arrives for +// timeout, instead of the 60 seconds the version manager waits by default. +// A zero timeout keeps the default. +func WithDownloadIdleTimeout(timeout time.Duration) Option { + return func(o *options) { + o.downloadIdleTimeout = timeout + } +} + +// deps holds what the commands of one tree share. The root command loads +// it before any command runs. +type deps struct { + opts options cfg *config.Config - gh *github.Client manager *version.Manager -) + errOut io.Writer -var rootCmd = &cobra.Command{ - Use: "educatesenv", - Short: "Manage multiple versions of the educates binary", - Long: `A version manager for educates, inspired by tfenv.`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Skip validation for help command - if cmd.Name() == "help" { - return nil - } + // token is resolved on first use, by githubToken, and gh is created on + // first use, by githubClient. + token *token.Token + gh *github.Client +} - // Validate development mode configuration - if err := manager.ValidateDevelopmentMode(); err != nil { - fmt.Printf("Warning: %v\n", err) - } - return nil - }, +// load reads the configuration and creates the version manager. The +// warning about a missing home directory and the manager's progress go to +// out, and warnings about config values, the manager's warnings and token +// warnings to errOut. It neither resolves the GitHub token nor creates the +// GitHub client. +func (d *deps) load(out, errOut io.Writer) error { + var err error + d.cfg, err = config.Load(out, errOut) + if err != nil { + return err + } + + d.errOut = errOut + d.manager = version.New(d.cfg, d.githubClient, out, errOut, version.WithDownloadIdleTimeout(d.opts.downloadIdleTimeout)) + return nil } -// Execute executes the root command -func Execute() error { - return rootCmd.Execute() +// githubToken returns the GitHub token, resolving it on first use. Only +// config view and the commands that call GitHub use it, so the others +// never look the token up. +func (d *deps) githubToken() token.Token { + if d.token == nil { + tok := token.Resolve(d.cfg.File(), d.cfg.Github.Token, d.errOut) + d.token = &tok + } + return *d.token } -func init() { - cobra.OnInitialize(initDependencies) +// githubClient returns the GitHub client, creating it on first use. +func (d *deps) githubClient() (*github.Client, error) { + if d.gh == nil { + gh, err := github.New(d.cfg, github.WithBaseURL(d.opts.githubBaseURL), github.WithToken(d.githubToken())) + if err != nil { + return nil, err + } + d.gh = gh + } + return d.gh, nil } -func initDependencies() { - // Initialize configuration - cfg = config.New() - if err := cfg.Load(); err != nil { - cobra.CheckErr(err) +// cleanUpDevelopmentSymlink removes the educates symlink when it points +// at a development binary while development mode is disabled, and warns +// about it. Only the commands that deal with the active binary run it, +// as their PreRun hook. +func (d *deps) cleanUpDevelopmentSymlink(cmd *cobra.Command, _ []string) { + if err := d.manager.ValidateDevelopmentMode(); err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Warning: %v\n", err) } +} - // Initialize GitHub client - gh = github.New(cfg) +// NewRootCommand builds the educatesenv command tree. Each call builds a +// new tree with its own flag values and dependencies. +func NewRootCommand(opts ...Option) *cobra.Command { + d := &deps{} + for _, opt := range opts { + opt(&d.opts) + } + + root := &cobra.Command{ + Use: "educatesenv", + Short: "Manage multiple versions of the educates binary", + Long: `A version manager for educates, inspired by tfenv.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return d.load(cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } - // Initialize version manager - manager = version.New(cfg, gh) + root.AddCommand( + newConfigCommand(d), + newInitCommand(d), + newInstallCommand(d), + newListCommand(d), + newListLocalCommand(d), + newListRemoteCommand(d), + newUninstallCommand(d), + newUseCommand(d), + newVersionCommand(), + ) + return root } diff --git a/pkg/cmd/token_test.go b/pkg/cmd/token_test.go new file mode 100644 index 0000000..2b93ecc --- /dev/null +++ b/pkg/cmd/token_test.go @@ -0,0 +1,264 @@ +package cmd_test + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" + "github.com/educates/educatesenv/internal/fakegithub" +) + +// The tokens each source holds in these tests. +const ( + educatesEnvToken = "educates-env-token" + ghEnvToken = "gh-env-token" + githubEnvToken = "github-env-token" + ghCLIToken = "gh-cli-token" + fileToken = "file-token" +) + +// tokenSource sets up one source of the GitHub token in env. +type tokenSource struct { + // name is how error messages name the source. + name string + token string + // label is how config view shows a token from this source. + label string + set func(env *clitest.Env) +} + +// tokenSources are the sources of the GitHub token, in the order +// educatesenv checks them. +var tokenSources = []tokenSource{ + {name: "EDUCATES_GITHUB_TOKEN", token: educatesEnvToken, label: "", set: func(env *clitest.Env) { + env.Setenv("EDUCATES_GITHUB_TOKEN", educatesEnvToken) + }}, + {name: "GH_TOKEN", token: ghEnvToken, label: "", set: func(env *clitest.Env) { + env.Setenv("GH_TOKEN", ghEnvToken) + }}, + {name: "GITHUB_TOKEN", token: githubEnvToken, label: "", set: func(env *clitest.Env) { + env.Setenv("GITHUB_TOKEN", githubEnvToken) + }}, + {name: "gh auth token", token: ghCLIToken, label: "", set: func(env *clitest.Env) { + fakeGHWithToken(env, ghCLIToken) + }}, + {name: "config file (deprecated)", token: fileToken, label: "", set: func(env *clitest.Env) { + env.WriteConfig("github:\n token: " + fileToken + "\n") + }}, +} + +// fakeGHWithToken puts a gh on the PATH that prints token for +// `gh auth token --hostname github.com` and fails for anything else. +func fakeGHWithToken(env *clitest.Env, token string) { + env.FakeGH(`if [ "$*" = "auth token --hostname github.com" ]; then printf '%s\n' '` + token + `'; exit 0; fi +exit 1`) +} + +// authorizations returns the Authorization header of every request the +// fake GitHub server received, failing the test when it received none. +func authorizations(t *testing.T, env *clitest.Env) []string { + t.Helper() + requests := env.GitHub.Requests() + require.NotEmpty(t, requests) + var headers []string + for _, r := range requests { + headers = append(headers, r.Header.Get("Authorization")) + } + return headers +} + +func TestTheTokenComesFromTheFirstSourceThatHasOne(t *testing.T) { + for i, winner := range tokenSources { + t.Run(winner.name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + for _, source := range tokenSources[i:] { + source.set(env) + } + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + for _, header := range authorizations(t, env) { + assert.Equal(t, "Bearer "+winner.token, header) + } + }) + } +} + +func TestAGHThatFailsPrintsNothingOrIsMissingGivesNoTokenSilently(t *testing.T) { + scripts := map[string]string{ + "exits non-zero": "printf 'gho_partial\\n'\nprintf 'gh: not logged in\\n' >&2\nexit 1", + "prints nothing": "exit 0", + "prints only whitespace": "printf ' \\n'", + "is not on the PATH": "", + } + + for name, script := range scripts { + t.Run(name, func(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + if script != "" { + env.FakeGH(script) + } + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) + assert.NotContains(t, res.Stdout, "gho_partial") + for _, header := range authorizations(t, env) { + assert.Empty(t, header) + } + }) + } +} + +func TestATokenFromTheConfigFileWarnsThatItIsDeprecated(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + configPath := env.WriteConfig("github:\n token: " + fileToken + "\n") + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Equal(t, "Warning: github.token in "+configPath+" is deprecated, and a later release will stop reading it. "+ + "Set GH_TOKEN or run 'gh auth login' instead, and remove the token from that file.\n", res.Stderr) + assert.NotContains(t, res.Stdout, fileToken) + for _, header := range authorizations(t, env) { + assert.Equal(t, "Bearer "+fileToken, header) + } +} + +func TestATokenInTheConfigFileThatAnotherSourceOverridesDoesNotWarn(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + env.WriteConfig("github:\n token: " + fileToken + "\n") + env.Setenv("GH_TOKEN", ghEnvToken) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) +} + +// recordingFakeGH puts a gh on the PATH that records the arguments of each +// call, and prints ghCLIToken for `gh auth token --hostname github.com`. +// It returns a function that returns the recorded calls. +func recordingFakeGH(t *testing.T, env *clitest.Env) func() []string { + t.Helper() + calls := filepath.Join(env.Home, "gh-calls") + env.FakeGH(`printf '%s\n' "$*" >> '` + calls + `' +if [ "$*" = "auth token --hostname github.com" ]; then printf '%s\n' '` + ghCLIToken + `'; exit 0; fi +exit 1`) + return func() []string { + content, err := os.ReadFile(calls) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + require.NoError(t, err) + return strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + } +} + +func TestCommandsThatDoNotCallGitHubNeverRunGH(t *testing.T) { + commands := [][]string{ + {"list"}, + {"use", "3.8.0"}, + {"version"}, + {"uninstall", "3.7.0"}, + {"init"}, + {"config", "init"}, + } + + for _, command := range commands { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + env.WriteBinary("3.7.0") + ghCalls := recordingFakeGH(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Empty(t, ghCalls()) + }) + } +} + +func TestCommandsThatCallGitHubRunGHOnce(t *testing.T) { + commands := [][]string{ + {"list-remote"}, + {"install", "3.8.0"}, + {"install", "latest"}, + {"init", "--download"}, + } + + for _, command := range commands { + t.Run(strings.Join(command, " "), func(t *testing.T) { + env := clitest.New(t) + addRelease(env, "3.8.0", []byte("educates 3.8.0 binary")) + ghCalls := recordingFakeGH(t, env) + + res := env.Run(command...) + + require.NoError(t, res.Err) + assert.Equal(t, []string{"auth token --hostname github.com"}, ghCalls()) + for _, r := range env.GitHub.Requests() { + if strings.HasPrefix(r.Path, "/repos/") { + assert.Equal(t, "Bearer "+ghCLIToken, r.Header.Get("Authorization"), r.Path) + } + } + }) + } +} + +func TestConfigViewShowsWhereTheTokenComesFromAndNoneOfIt(t *testing.T) { + for i, winner := range tokenSources { + t.Run(winner.name, func(t *testing.T) { + env := clitest.New(t) + for _, source := range tokenSources[i:] { + source.set(env) + } + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "github:\n org: educates\n repository: educates-training-platform\n token: "+winner.label+"\n") + for _, source := range tokenSources { + assert.NotContains(t, res.Stdout, source.token) + assert.NotContains(t, res.Stderr, source.token) + } + }) + } + + t.Run("not set", func(t *testing.T) { + env := clitest.New(t) + + res := env.Run("config", "view") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, " repository: educates-training-platform\n token: \n") + assert.Empty(t, res.Stderr) + }) +} + +func TestWithoutATokenRequestsAreUnauthenticated(t *testing.T) { + env := clitest.New(t) + env.GitHub.AddReleases(fakegithub.Release{Tag: "3.8.0"}) + + res := env.Run("list-remote") + + require.NoError(t, res.Err) + assert.Empty(t, res.Stderr) + for _, header := range authorizations(t, env) { + assert.Empty(t, header) + } +} diff --git a/pkg/cmd/uninstall.go b/pkg/cmd/uninstall.go new file mode 100644 index 0000000..7178857 --- /dev/null +++ b/pkg/cmd/uninstall.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newUninstallCommand(d *deps) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "uninstall ...", + Short: "Uninstall one or more versions of educates", + Args: cobra.MinimumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return d.manager.UninstallVersions(args, force) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Uninstall the active version too, leaving no version active") + return cmd +} diff --git a/pkg/cmd/uninstall_test.go b/pkg/cmd/uninstall_test.go new file mode 100644 index 0000000..5e7fab5 --- /dev/null +++ b/pkg/cmd/uninstall_test.go @@ -0,0 +1,184 @@ +package cmd_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestUninstallRemovesAVersion(t *testing.T) { + env := clitest.New(t) + removed := env.WriteBinary("3.7.0") + kept := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\n", res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, removed) + assert.FileExists(t, kept) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallRemovesSeveralVersions(t *testing.T) { + env := clitest.New(t) + first := env.WriteBinary("3.6.0") + second := env.WriteBinary("3.7.0") + kept := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0", "3.6.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\neducates 3.6.0 uninstalled.\n", res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, first) + assert.NoFileExists(t, second) + assert.FileExists(t, kept) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfAVersionNamedTwiceRemovesItOnce(t *testing.T) { + env := clitest.New(t) + removed := env.WriteBinary("3.7.0") + + res := env.Run("uninstall", "3.7.0", "3.7.0") + + require.NoError(t, res.Err) + assert.Equal(t, "educates 3.7.0 uninstalled.\n", res.Stdout) + assert.NoFileExists(t, removed) +} + +func TestUninstallRequiresAVersion(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.8.0") + + res := env.Run("uninstall") + + require.EqualError(t, res.Err, "requires at least 1 arg(s), only received 0") + assert.Empty(t, res.Stdout) + assert.FileExists(t, kept) +} + +func TestUninstallOfAVersionThatIsNotInstalledFails(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.8.0") + + res := env.Run("uninstall", "3.9.9") + + require.EqualError(t, res.Err, "version 3.9.9 is not installed. Run 'educatesenv list' to see installed versions") + assert.Empty(t, res.Stdout) + assert.FileExists(t, kept) +} + +func TestUninstallOfAPathIsNotAnInstalledVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + configPath := env.WriteConfig("github:\n org: educates\n") + + // Joined to the bin directory, this argument names the config file. + res := env.Run("uninstall", "x/../../config.yaml") + + require.EqualError(t, res.Err, "version x/../../config.yaml is not installed. Run 'educatesenv list' to see installed versions") + assert.FileExists(t, configPath) +} + +func TestUninstallWithATypoAmongValidVersionsRemovesNothing(t *testing.T) { + env := clitest.New(t) + first := env.WriteBinary("3.6.0") + second := env.WriteBinary("3.7.0") + + res := env.Run("uninstall", "3.6.0", "3.7.O", "3.7.0") + + require.EqualError(t, res.Err, "version 3.7.O is not installed. Run 'educatesenv list' to see installed versions") + assert.Empty(t, res.Stdout) + assert.FileExists(t, first) + assert.FileExists(t, second) +} + +func TestUninstallReportsEveryVersionItCannotUninstall(t *testing.T) { + env := clitest.New(t) + valid := env.WriteBinary("3.6.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.6.0", "3.7.O", "develop", "3.8.0") + + require.EqualError(t, res.Err, + "version 3.7.O is not installed. Run 'educatesenv list' to see installed versions\n"+ + "cannot uninstall develop: educatesenv does not manage the development binary\n"+ + "version 3.8.0 is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it") + assert.Empty(t, res.Stdout) + assert.FileExists(t, valid) + assert.FileExists(t, active) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfDevelopFails(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + // An educates-develop file in the bin directory does not make develop + // an installed version. + shadow := env.WriteBinary("develop") + require.NoError(t, os.Symlink(devBinary, env.ActiveLink())) + + res := env.Run("uninstall", "develop", "--force") + + require.EqualError(t, res.Err, "cannot uninstall develop: educatesenv does not manage the development binary") + assert.Empty(t, res.Stdout) + assert.FileExists(t, devBinary) + assert.FileExists(t, shadow) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, target) +} + +func TestUninstallOfTheActiveVersionFailsWithoutForce(t *testing.T) { + env := clitest.New(t) + other := env.WriteBinary("3.7.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.7.0", "3.8.0") + + require.EqualError(t, res.Err, "version 3.8.0 is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it") + assert.Empty(t, res.Stdout) + assert.FileExists(t, other) + assert.FileExists(t, active) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUninstallOfTheActiveVersionWithForceLeavesNoVersionActive(t *testing.T) { + env := clitest.New(t) + kept := env.WriteBinary("3.7.0") + active := env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("uninstall", "3.8.0", "--force") + + require.NoError(t, res.Err) + assert.Equal(t, + "educates 3.8.0 uninstalled.\n"+ + "No educates version is active now. Run 'educatesenv use ' to select one.\n", + res.Stdout) + assert.Empty(t, res.Stderr) + assert.NoFileExists(t, active) + assert.FileExists(t, kept) + _, err := os.Lstat(env.ActiveLink()) + assert.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/pkg/cmd/use.go b/pkg/cmd/use.go index b93a4ed..5e66f7a 100644 --- a/pkg/cmd/use.go +++ b/pkg/cmd/use.go @@ -1,56 +1,68 @@ package cmd import ( + "errors" "fmt" + "io/fs" "os" "github.com/spf13/cobra" + + "github.com/educates/educatesenv/pkg/version" ) -var useCmd = &cobra.Command{ - Use: "use [version|develop]", - Short: "Switch to a specific educates version", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] - - // Handle development version - if version == "develop" { - if !cfg.Development.Enabled { - return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") - } - if cfg.Development.BinaryLocation == "" { - return fmt.Errorf("development binary location is not set. Set development.binaryLocation in the config file") - } +func newUseCommand(d *deps) *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Switch to a specific educates version", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + PreRun: d.cleanUpDevelopmentSymlink, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] - // Check if the development binary exists - if _, err := os.Stat(cfg.Development.BinaryLocation); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("development binary not found at %s", cfg.Development.BinaryLocation) + // Handle development version + if name == version.Develop { + if !d.cfg.Development.Enabled { + return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") + } + if d.cfg.Development.BinaryLocation == "" { + return fmt.Errorf("development binary location is not set. Set development.binaryLocation in the config file") } - return fmt.Errorf("failed to check development binary: %w", err) - } - if err := manager.UseVersion(version); err != nil { - return fmt.Errorf("failed to switch to development version: %w", err) - } + // Check if the development binary exists + if _, err := os.Stat(d.cfg.Development.BinaryLocation); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("development binary not found at %s", d.cfg.Development.BinaryLocation) + } + return fmt.Errorf("failed to check development binary: %w", err) + } - fmt.Printf("Now using educates development version from %s\n", cfg.Development.BinaryLocation) - return nil - } + if err := d.manager.UseVersion(name); err != nil { + return fmt.Errorf("failed to switch to development version: %w", err) + } - // Handle regular version - if err := manager.UseVersion(version); err != nil { - return fmt.Errorf("version %s is not installed. You should install it first with `educatesenv install %s`", version, version) - } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates development version from %s\n", d.cfg.Development.BinaryLocation) + return nil + } - fmt.Printf("Now using educates version %s\n", version) - return nil - }, -} + // Handle regular version + if err := d.manager.UseVersion(name); err != nil { + switch { + // install resolves latest to a version, while use only + // switches to a version that is installed. + case errors.Is(err, fs.ErrNotExist) && name == version.Latest: + return fmt.Errorf("use takes an installed version, not %[1]s. "+ + "Run 'educatesenv install %[1]s --use' to install the latest stable release and switch to it", version.Latest) + case errors.Is(err, fs.ErrNotExist): + return fmt.Errorf("version %s is not installed. You should install it first with 'educatesenv install %s'", name, name) + } + return fmt.Errorf("failed to switch to version %s: %w", name, err) + } -func init() { - rootCmd.AddCommand(useCmd) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Now using educates version %s\n", name) + return nil + }, + } } diff --git a/pkg/cmd/use_test.go b/pkg/cmd/use_test.go new file mode 100644 index 0000000..a68228c --- /dev/null +++ b/pkg/cmd/use_test.go @@ -0,0 +1,115 @@ +package cmd_test + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestUseShowsItsRequiredArgumentInItsUsage(t *testing.T) { + env := clitest.New(t) + + res := env.Run("use", "--help") + + require.NoError(t, res.Err) + assert.Contains(t, res.Stdout, "Usage:\n educatesenv use [flags]\n") +} + +func TestUseActivatesAnInstalledVersion(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.7.0") + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.7.0", env.ActiveLink())) + + res := env.Run("use", "3.8.0") + + require.NoError(t, res.Err) + assert.Equal(t, "Now using educates version 3.8.0\n", res.Stdout) + assert.Empty(t, res.Stderr) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUseOfAVersionThatIsNotInstalledSaysHowToInstallIt(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("use", "3.9.0") + + require.EqualError(t, res.Err, "version 3.9.0 is not installed. You should install it first with 'educatesenv install 3.9.0'") + assert.Empty(t, res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUseLatestSaysHowToInstallAndUseTheLatestRelease(t *testing.T) { + env := clitest.New(t) + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.8.0", env.ActiveLink())) + + res := env.Run("use", "latest") + + require.EqualError(t, res.Err, "use takes an installed version, not latest. Run 'educatesenv install latest --use' to install the latest stable release and switch to it") + assert.Empty(t, res.Stdout) + assert.Empty(t, env.GitHub.Requests()) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.8.0", target) +} + +func TestUseWithAnUnwritableBinDirectoryReportsTheCause(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("directory permissions do not apply to root") + } + env := clitest.New(t) + env.WriteBinary("3.7.0") + env.WriteBinary("3.8.0") + require.NoError(t, os.Symlink("educates-3.7.0", env.ActiveLink())) + require.NoError(t, os.Chmod(env.BinDir(), 0o555)) + t.Cleanup(func() { + _ = os.Chmod(env.BinDir(), 0o755) + }) + + res := env.Run("use", "3.8.0") + + require.ErrorIs(t, res.Err, fs.ErrPermission) + assert.ErrorContains(t, res.Err, "failed to switch to version 3.8.0: ") + assert.NotContains(t, res.Err.Error(), "not installed") + assert.Empty(t, res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, "educates-3.7.0", target) +} + +func TestUseDevelopRequiresDevelopmentMode(t *testing.T) { + env := clitest.New(t) + + res := env.Run("use", "develop") + + require.EqualError(t, res.Err, "development mode is not enabled. Enable it in the config file by setting development.enabled to true") + assert.NoFileExists(t, env.ActiveLink()) +} + +func TestUseDevelopActivatesTheDevelopmentBinary(t *testing.T) { + env := clitest.New(t) + devBinary := writeDevelopmentBinary(t, env) + env.WriteConfig("development:\n enabled: true\n binaryLocation: " + devBinary + "\n") + require.NoError(t, os.MkdirAll(env.BinDir(), 0o755)) + + res := env.Run("use", "develop") + + require.NoError(t, res.Err) + assert.Equal(t, "Now using educates development version from "+devBinary+"\n", res.Stdout) + target, err := os.Readlink(env.ActiveLink()) + require.NoError(t, err) + assert.Equal(t, devBinary, filepath.Join(env.BinDir(), target)) +} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 61b35df..4392cc0 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -7,18 +7,16 @@ import ( "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print client version", - Args: cobra.ExactArgs(0), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(_ *cobra.Command, _ []string) error { - fmt.Println(version.Version) - return nil - }, -} - -func init() { - rootCmd.AddCommand(versionCmd) +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print client version", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), version.Version) + return nil + }, + } } diff --git a/pkg/cmd/version_test.go b/pkg/cmd/version_test.go new file mode 100644 index 0000000..dc62786 --- /dev/null +++ b/pkg/cmd/version_test.go @@ -0,0 +1,20 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/educates/educatesenv/internal/clitest" +) + +func TestVersionPrintsTheBuildVersion(t *testing.T) { + env := clitest.New(t) + + res := env.Run("version") + + require.NoError(t, res.Err) + assert.Equal(t, "develop\n", res.Stdout) + assert.Empty(t, res.Stderr) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b865207..bf89400 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,12 +1,19 @@ +// Package config loads educatesenv's configuration from the config file in +// the user's educatesenv directory and from EDUCATES_* environment +// variables. package config import ( + "errors" "fmt" + "io" + "io/fs" "os" "path/filepath" + "strconv" + "strings" - "github.com/spf13/viper" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) const ( @@ -16,13 +23,19 @@ const ( DefaultGithubRepo = "educates-training-platform" // ConfigDirName is the name of the directory where educatesenv stores its configuration ConfigDirName = ".educatesenv" + // ConfigFileName is the name of the config file in the educatesenv directory + ConfigFileName = "config.yaml" ) -// GithubConfig holds GitHub-related configuration +// GithubConfig holds GitHub-related configuration. type GithubConfig struct { Org string `yaml:"org"` Repository string `yaml:"repository"` - Token string `yaml:"token"` + // Token is github.token from the config file, the last and deprecated + // source of the GitHub token. The token package resolves the token + // from every source. An empty token is left out of the YAML, so the + // default config file holds no token key. + Token string `yaml:"token,omitempty"` } // LocalConfig holds local directory configuration @@ -41,128 +54,306 @@ type Config struct { Github GithubConfig `yaml:"github"` Local LocalConfig `yaml:"local"` Development DevelopmentConfig `yaml:"development"` + + file string +} + +// File returns the path of the config file Load read the configuration +// from, whether or not the file exists. It is empty when Load could not +// determine the home directory. +func (c *Config) File() string { + return c.file +} + +// Paths are the places educatesenv keeps its files in. +type Paths struct { + // Home is the user's home directory. + Home string + // ConfigDir is the educatesenv directory in Home. + ConfigDir string + // BinDir is the default directory installed versions go in. + BinDir string + // ConfigFile is the config file in ConfigDir. + ConfigFile string } -// New returns a new Config instance with defaults set -func New() *Config { +// homePaths returns the paths in the user's home directory. +func homePaths() (Paths, error) { home, err := os.UserHomeDir() if err != nil { - fmt.Println("Warning: could not determine home directory, using current directory for bin folder.") - home = "." + return Paths{}, fmt.Errorf("could not determine home directory: %w", err) } + return pathsIn(home), nil +} + +// pathsIn returns the paths in home. +func pathsIn(home string) Paths { configDir := filepath.Join(home, ConfigDirName) - defaultBin := filepath.Join(configDir, "bin") + return Paths{ + Home: home, + ConfigDir: configDir, + BinDir: filepath.Join(configDir, "bin"), + ConfigFile: filepath.Join(configDir, ConfigFileName), + } +} +// defaults returns the configuration that applies when neither the config +// file nor the environment sets a value. +func defaults(paths Paths) *Config { return &Config{ Github: GithubConfig{ Org: DefaultGithubOrg, Repository: DefaultGithubRepo, - Token: "", }, Local: LocalConfig{ - Dir: defaultBin, - }, - Development: DevelopmentConfig{ - Enabled: false, - BinaryLocation: "", + Dir: paths.BinDir, }, } } -// Load initializes the configuration from file and environment variables -func (c *Config) Load() error { - home, err := os.UserHomeDir() +// Load returns the configuration: the defaults, overridden by the config +// file in the user's educatesenv directory, overridden in turn by the +// EDUCATES_* environment variables. An environment variable set to the +// empty string counts as not set. The GitHub token is not among them: the +// token package reads EDUCATES_GITHUB_TOKEN. +// +// A boolean value in none of the forms boolOf accepts reads as false, and +// Load writes a warning that names the value's key and file, or its +// environment variable, to errOut, instead of failing. +// +// Without a home directory, Load writes a warning to out, reads no config +// file, and puts the bin directory under the current directory. +func Load(out, errOut io.Writer) (*Config, error) { + paths, err := homePaths() if err != nil { - fmt.Println("Warning: could not determine home directory, using current directory for bin folder.") - home = "." + _, _ = fmt.Fprintln(out, "Warning: could not determine home directory, using current directory for bin folder.") + cfg := defaults(pathsIn(".")) + cfg.applyEnv(errOut) + return cfg, nil } - configDir := filepath.Join(home, ConfigDirName) - defaultBin := filepath.Join(configDir, "bin") - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - viper.AddConfigPath(configDir) + cfg := defaults(paths) + cfg.file = paths.ConfigFile + if err := cfg.applyFile(paths.ConfigFile, errOut); err != nil { + return nil, err + } + cfg.applyEnv(errOut) + return cfg, nil +} - // Set defaults - viper.SetDefault("github.org", DefaultGithubOrg) - viper.SetDefault("github.repository", DefaultGithubRepo) - viper.SetDefault("github.token", "") - viper.SetDefault("local.dir", defaultBin) - viper.SetDefault("development.enabled", false) - viper.SetDefault("development.binaryLocation", "") +// setting is one value of the configuration. +type setting struct { + // key is the value's key in the config file, as section.key. Keys + // match regardless of case. + key string + // env is the environment variable that overrides the config file, or + // "" for a value only the config file sets. + env string + // Exactly one of str and boolean points at the field the value sets. + str *string + boolean *bool +} - // Bind environment variables - if err := viper.BindEnv("github.org", "EDUCATES_GITHUB_ORG"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_ORG: %w", err) +func (c *Config) settings() []setting { + return []setting{ + {key: "github.org", env: "EDUCATES_GITHUB_ORG", str: &c.Github.Org}, + {key: "github.repository", env: "EDUCATES_GITHUB_REPOSITORY", str: &c.Github.Repository}, + {key: "github.token", str: &c.Github.Token}, + {key: "local.dir", env: "EDUCATES_LOCAL_DIR", str: &c.Local.Dir}, + {key: "development.enabled", env: "EDUCATES_DEVELOPMENT_ENABLED", boolean: &c.Development.Enabled}, + {key: "development.binaryLocation", env: "EDUCATES_DEVELOPMENT_BINARY_LOCATION", str: &c.Development.BinaryLocation}, } - if err := viper.BindEnv("github.repository", "EDUCATES_GITHUB_REPOSITORY"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_REPOSITORY: %w", err) +} + +// boolWarning is the warning for a boolean value that reads as false +// because boolOf does not accept it. Its verbs are where the value is set +// and what is wrong with it. +const boolWarning = "Warning: %s %s, so it is read as false. Set it to true or false.\n" + +// notABoolean says that value is not a boolean, for boolWarning. +func notABoolean(value string) string { + return fmt.Sprintf("is %q, which is not a boolean", value) +} + +// applyEnv sets every value whose environment variable is set and not +// empty, and writes a warning to warnings for each boolean that reads as +// false because boolOf does not accept it. +func (c *Config) applyEnv(warnings io.Writer) { + for _, s := range c.settings() { + if s.env == "" { + continue + } + value := os.Getenv(s.env) + if value == "" { + continue + } + if s.str != nil { + *s.str = value + continue + } + b, ok := boolOf(value) + if !ok { + _, _ = fmt.Fprintf(warnings, boolWarning, s.env, notABoolean(value)) + } + *s.boolean = b } - if err := viper.BindEnv("github.token", "EDUCATES_GITHUB_TOKEN"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_GITHUB_TOKEN: %w", err) +} + +// applyFile sets every value the config file at path sets, and writes a +// warning to warnings for each boolean that reads as false because boolOf +// does not accept it. A missing file sets nothing, and so does a key whose +// value is null. +func (c *Config) applyFile(path string, warnings io.Writer) error { + values, err := readFile(path) + if err != nil { + return err } - if err := viper.BindEnv("local.dir", "EDUCATES_LOCAL_DIR"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_LOCAL_DIR: %w", err) + + for _, s := range c.settings() { + n, ok := values[strings.ToLower(s.key)] + if !ok || n.ShortTag() == "!!null" { + continue + } + if s.boolean != nil { + b, ok := fileBool(n) + if !ok { + problem := "is not a single value" + if n.Kind == yaml.ScalarNode { + problem = notABoolean(n.Value) + } + _, _ = fmt.Fprintf(warnings, boolWarning, s.key+" in "+path, problem) + } + *s.boolean = b + continue + } + if n.Kind != yaml.ScalarNode { + return fmt.Errorf("%s in %s: expected a single value", s.key, path) + } + *s.str = n.Value } - if err := viper.BindEnv("development.enabled", "EDUCATES_DEVELOPMENT_ENABLED"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_ENABLED: %w", err) + return nil +} + +// readFile returns the values in the config file at path, by their key as +// section.key in lower case. When a key appears more than once in +// different cases, the last one wins. +func readFile(path string) (map[string]*yaml.Node, error) { + content, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil, fmt.Errorf("error reading config file %s: %w", path, err) + } + if len(doc.Content) == 0 { + return nil, nil + } + + root := resolve(doc.Content[0]) + if root.ShortTag() == "!!null" { + return nil, nil } - if err := viper.BindEnv("development.binaryLocation", "EDUCATES_DEVELOPMENT_BINARY_LOCATION"); err != nil { - return fmt.Errorf("failed to bind env EDUCATES_DEVELOPMENT_BINARY_LOCATION: %w", err) + if root.Kind != yaml.MappingNode { + return nil, fmt.Errorf("error reading config file %s: expected a mapping of sections", path) } - // Read config file if present - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return fmt.Errorf("error reading config file: %w", err) + values := map[string]*yaml.Node{} + for i := 0; i+1 < len(root.Content); i += 2 { + section, body := root.Content[i].Value, resolve(root.Content[i+1]) + if body.Kind != yaml.MappingNode { + continue + } + for j := 0; j+1 < len(body.Content); j += 2 { + key := strings.ToLower(section + "." + body.Content[j].Value) + values[key] = resolve(body.Content[j+1]) } } + return values, nil +} - // Map the configuration to our struct - c.Github.Org = viper.GetString("github.org") - c.Github.Repository = viper.GetString("github.repository") - c.Github.Token = viper.GetString("github.token") - c.Local.Dir = viper.GetString("local.dir") - c.Development.Enabled = viper.GetBool("development.enabled") - c.Development.BinaryLocation = viper.GetString("development.binaryLocation") +// resolve returns the node an alias points at, or n itself. +func resolve(n *yaml.Node) *yaml.Node { + for n.Kind == yaml.AliasNode { + n = n.Alias + } + return n +} - return nil +// boolOf reads value as a boolean: a bool as itself, a number as true when +// it is not zero, and a string in the forms strconv.ParseBool accepts: 1, +// t, T, TRUE, true, True, 0, f, F, FALSE, false and False. Any other value +// reads as false, and ok is false. These are the forms and the readings of +// viper's GetBool, so a config written for educatesenv 1.0 reads the same. +func boolOf(value any) (b, ok bool) { + switch v := value.(type) { + case bool: + return v, true + case int: + return v != 0, true + case int64: + return v != 0, true + case uint64: + return v != 0, true + case float64: + return v != 0, true + case string: + b, err := strconv.ParseBool(v) + return b, err == nil + default: + return false, false + } } -// CreateConfigAndFolders ensures the config and bin directories exist, and creates a default config.yaml if not present. -// Returns (configDir, binDir, configPath, configCreated, error) -func CreateConfigAndFolders() (string, string, string, bool, error) { - home, err := os.UserHomeDir() +// fileBool reads a boolean from the config file by boolOf, from the value +// YAML decodes n to: a bool for true or false, a number for an integer or +// a float, and a string for any other single value, such as yes or on. +func fileBool(n *yaml.Node) (b, ok bool) { + var value any + if err := n.Decode(&value); err != nil { + return false, false + } + return boolOf(value) +} + +// CreateDefault creates the educatesenv directory in the user's home +// directory and its bin directory, and writes the default config file there +// unless a config file exists. The file is readable only by its owner and +// holds no token. CreateDefault returns the paths and whether it wrote the +// file, and fails when the home directory cannot be determined. +func CreateDefault() (Paths, bool, error) { + paths, err := homePaths() if err != nil { - home = "." + return Paths{}, false, err } - configDir := filepath.Join(home, ConfigDirName) - binDir := filepath.Join(configDir, "bin") - configPath := filepath.Join(configDir, "config.yaml") - - if err := os.MkdirAll(binDir, 0o755); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to create bin directory: %w", err) - } - if err := os.MkdirAll(configDir, 0o755); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to create config directory: %w", err) - } - - configCreated := false - if _, err := os.Stat(configPath); err == nil { - // Config file already exists - configCreated = false - } else { - // Create new config with defaults - configFile := New() - yamlBytes, err := yaml.Marshal(&configFile) - if err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to marshal config to YAML: %w", err) - } - if err := os.WriteFile(configPath, yamlBytes, 0o644); err != nil { - return configDir, binDir, configPath, false, fmt.Errorf("failed to write config file: %w", err) - } - configCreated = true + + if err := os.MkdirAll(paths.BinDir, 0o755); err != nil { + return paths, false, fmt.Errorf("failed to create bin directory: %w", err) + } + + content, err := yaml.Marshal(defaults(paths)) + if err != nil { + return paths, false, fmt.Errorf("failed to marshal config to YAML: %w", err) + } + + f, err := os.OpenFile(paths.ConfigFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, fs.ErrExist) { + return paths, false, nil + } + if err != nil { + return paths, false, fmt.Errorf("failed to write config file: %w", err) + } + _, err = f.Write(content) + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + _ = os.Remove(paths.ConfigFile) + return paths, false, fmt.Errorf("failed to write config file: %w", err) } - return configDir, binDir, configPath, configCreated, nil + return paths, true, nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go deleted file mode 100644 index 24b13a2..0000000 --- a/pkg/config/config_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNew(t *testing.T) { - cfg := New() - assert.NotNil(t, cfg) - assert.Equal(t, DefaultGithubOrg, cfg.Github.Org) - assert.Equal(t, DefaultGithubRepo, cfg.Github.Repository) - assert.Empty(t, cfg.Github.Token) - assert.False(t, cfg.Development.Enabled) - assert.Empty(t, cfg.Development.BinaryLocation) - - // Test that Local.Dir is set to a path in the home directory - home, err := os.UserHomeDir() - if err == nil { - expectedPath := filepath.Join(home, ConfigDirName, "bin") - assert.Equal(t, expectedPath, cfg.Local.Dir) - } -} - -func TestLoad(t *testing.T) { - // Setup temporary directory for test config - tmpDir, err := os.MkdirTemp("", "educatesenv-test") - assert.NoError(t, err) - defer func() { - err := os.RemoveAll(tmpDir) - assert.NoError(t, err) - }() - - // Create a test config file - configContent := []byte(` -github: - org: testorg - repository: testrepo - token: testtoken -local: - dir: /test/dir -development: - enabled: true - binaryLocation: /test/binary -`) - err = os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configContent, 0644) - assert.NoError(t, err) - - // Set working directory to temp dir for config loading - oldWd, err := os.Getwd() - assert.NoError(t, err) - err = os.Chdir(tmpDir) - assert.NoError(t, err) - defer func() { - err := os.Chdir(oldWd) - assert.NoError(t, err) - }() - - // Test loading config from file - cfg := New() - err = cfg.Load() - assert.NoError(t, err) - assert.Equal(t, "testorg", cfg.Github.Org) - assert.Equal(t, "testrepo", cfg.Github.Repository) - assert.Equal(t, "testtoken", cfg.Github.Token) - assert.Equal(t, "/test/dir", cfg.Local.Dir) - assert.True(t, cfg.Development.Enabled) - assert.Equal(t, "/test/binary", cfg.Development.BinaryLocation) -} - -func TestLoadWithEnvVars(t *testing.T) { - // Set environment variables - envVars := map[string]string{ - "EDUCATES_GITHUB_ORG": "envorg", - "EDUCATES_GITHUB_REPOSITORY": "envrepo", - "EDUCATES_GITHUB_TOKEN": "envtoken", - "EDUCATES_LOCAL_DIR": "/env/dir", - "EDUCATES_DEVELOPMENT_ENABLED": "true", - "EDUCATES_DEVELOPMENT_BINARY_LOCATION": "/env/binary", - } - - // Set environment variables - for k, v := range envVars { - err := os.Setenv(k, v) - assert.NoError(t, err) - defer func() { - err := os.Unsetenv(k) - assert.NoError(t, err) - }() - } - - // Test loading config with environment variables - cfg := New() - err := cfg.Load() - assert.NoError(t, err) - assert.Equal(t, "envorg", cfg.Github.Org) - assert.Equal(t, "envrepo", cfg.Github.Repository) - assert.Equal(t, "envtoken", cfg.Github.Token) - assert.Equal(t, "/env/dir", cfg.Local.Dir) - assert.True(t, cfg.Development.Enabled) - assert.Equal(t, "/env/binary", cfg.Development.BinaryLocation) -} diff --git a/pkg/github/client.go b/pkg/github/client.go index c6dcd6a..33b4b5a 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -2,82 +2,172 @@ package github import ( "context" + "errors" "fmt" + "net/http" + "time" "github.com/educates/educatesenv/pkg/config" - "github.com/google/go-github/v71/github" - "golang.org/x/oauth2" + "github.com/educates/educatesenv/pkg/releases" + "github.com/educates/educatesenv/pkg/token" + "github.com/google/go-github/v93/github" ) // Client wraps the GitHub client with our configuration type Client struct { client *github.Client config *config.Config + token token.Token } -// New creates a new GitHub client with optional authentication -func New(cfg *config.Config) *Client { - ctx := context.Background() - var client *github.Client +// options holds the settings that Option values change. +type options struct { + baseURL string + token token.Token +} + +// Option changes how New builds a Client. +type Option func(*options) - if cfg.Github.Token != "" { - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.Github.Token}) - client = github.NewClient(oauth2.NewClient(ctx, ts)) - } else { - client = github.NewClient(nil) +// WithBaseURL sets the GitHub API base URL. The default is github.com's API. +func WithBaseURL(baseURL string) Option { + return func(o *options) { + o.baseURL = baseURL } +} - return &Client{ - client: client, - config: cfg, +// WithToken authenticates the client's requests with tok. Without a set +// token, the client calls the API unauthenticated. +func WithToken(tok token.Token) Option { + return func(o *options) { + o.token = tok } } -// GetLatestReleaseVersion returns the latest stable release version -func (c *Client) GetLatestReleaseVersion() (string, error) { - releases, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, &github.ListOptions{PerPage: 10}) +// New creates a new GitHub client for the repository cfg names +func New(cfg *config.Config, opts ...Option) (*Client, error) { + var o options + for _, opt := range opts { + opt(&o) + } + + var clientOpts []github.ClientOptionsFunc + if o.token.IsSet() { + clientOpts = append(clientOpts, github.WithAuthToken(o.token.Value())) + } + if o.baseURL != "" { + clientOpts = append(clientOpts, github.WithURLs(&o.baseURL, nil)) + } + + client, err := github.NewClient(clientOpts...) if err != nil { - if resp != nil && resp.StatusCode == 404 { - return "", fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) - } - return "", fmt.Errorf("failed to fetch releases: %w", err) + return nil, fmt.Errorf("failed to create GitHub client: %w", err) } - if len(releases) == 0 { - return "", fmt.Errorf("no releases found in %s/%s", c.config.Github.Org, c.config.Github.Repository) + return &Client{ + client: client, + config: cfg, + token: o.token, + }, nil +} + +// ChecksumsAssetName is the name of the release asset that lists the +// SHA-256 checksum of each of the release's other assets. +const ChecksumsAssetName = "checksums.txt" + +// ReleaseAssets holds the download URLs of the release assets that +// installing a version needs. +type ReleaseAssets struct { + // BinaryURL is the download URL of the platform binary. + BinaryURL string + // ChecksumsURL is the download URL of checksums.txt. It is empty when + // the release has no checksums.txt. + ChecksumsURL string +} + +// GetReleaseAssets looks up the release of a version by its tag, and +// returns the download URLs of its binaryName asset and of its +// checksums.txt. +func (c *Client) GetReleaseAssets(version, binaryName string) (ReleaseAssets, error) { + release, _, err := c.client.Repositories.GetReleaseByTag(context.Background(), c.config.Github.Org, c.config.Github.Repository, version) + if err != nil { + notFound := fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) + return ReleaseAssets{}, c.apiError(err, notFound, "failed to fetch release info") } - for _, rel := range releases { - if rel.TagName != nil && !rel.GetPrerelease() { - return *rel.TagName, nil + var assets ReleaseAssets + for _, a := range release.Assets { + switch a.GetName() { + case binaryName: + assets.BinaryURL = a.GetBrowserDownloadURL() + case ChecksumsAssetName: + assets.ChecksumsURL = a.GetBrowserDownloadURL() } } - return "", fmt.Errorf("no stable releases found in %s/%s. Try 'educatesenv list-remote --all' to see pre-releases", c.config.Github.Org, c.config.Github.Repository) + if assets.BinaryURL == "" { + return ReleaseAssets{}, fmt.Errorf("binary for %s is not available for your platform (%s). Please check supported platforms in the documentation", version, binaryName) + } + return assets, nil } -// GetReleaseAssetURL gets the download URL for a specific version and platform -func (c *Client) GetReleaseAssetURL(version, assetName string) (string, error) { - release, resp, err := c.client.Repositories.GetReleaseByTag(context.Background(), c.config.Github.Org, c.config.Github.Repository, version) - if err != nil { - if resp != nil && resp.StatusCode == 404 { - return "", fmt.Errorf("version %s not found. Run 'educatesenv list-remote' to see available versions", version) +// ListReleases returns every release of the repository, drafts included, +// in the order GitHub lists them. It reads every page, 100 releases at a +// time. +func (c *Client) ListReleases() ([]releases.Release, error) { + var all []releases.Release + opts := &github.ListOptions{PerPage: 100} + for { + page, resp, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, opts) + if err != nil { + notFound := fmt.Errorf("repository %s/%s not found", c.config.Github.Org, c.config.Github.Repository) + return nil, c.apiError(err, notFound, "failed to fetch releases") } - return "", fmt.Errorf("failed to fetch release info: %w", err) + + for _, rel := range page { + all = append(all, releases.Release{ + Tag: rel.GetTagName(), + PublishedAt: rel.GetPublishedAt().Time, + GitHubPrerelease: rel.GetPrerelease(), + Draft: rel.GetDraft(), + }) + } + + if resp.NextPage == 0 { + return all, nil + } + opts.Page = resp.NextPage } +} - for _, a := range release.Assets { - if a.GetName() == assetName { - return a.GetBrowserDownloadURL(), nil +// apiError returns the error the user sees for err, the error of a GitHub +// API call. A rate limit says when it resets, and a token GitHub rejects +// with a 401 is named by its source. A 404 gives notFound. Any other error +// is wrapped, with failure saying what failed. +func (c *Client) apiError(err, notFound error, failure string) error { + if rateLimit, ok := errors.AsType[*github.RateLimitError](err); ok { + return c.rateLimitError(rateLimit.Rate.Reset.Time) + } + if response, ok := errors.AsType[*github.ErrorResponse](err); ok && response.Response != nil { + switch status := response.Response.StatusCode; { + case status == http.StatusUnauthorized && c.token.IsSet(): + message := response.Message + if message == "" { + message = http.StatusText(status) + } + return fmt.Errorf("GitHub rejected the token from %s: %d %s. Replace or remove that token and try again", c.token.Source(), status, message) + case status == http.StatusNotFound: + return notFound } } - return "", fmt.Errorf("binary for %s is not available for your platform (%s). Please check supported platforms in the documentation", version, assetName) + return fmt.Errorf("%s: %w", failure, err) } -// ListReleases returns all releases from the repository -func (c *Client) ListReleases() ([]*github.RepositoryRelease, error) { - releases, _, err := c.client.Repositories.ListReleases(context.Background(), c.config.Github.Org, c.config.Github.Repository, &github.ListOptions{PerPage: 100}) - if err != nil { - return nil, fmt.Errorf("failed to fetch releases: %w", err) +// rateLimitError returns the error for a rate limit that resets at reset. +// Without a token, it says how to authenticate, which raises the limit. +func (c *Client) rateLimitError(reset time.Time) error { + resets := reset.UTC().Format(time.DateTime) + " UTC" + if c.token.IsSet() { + return fmt.Errorf("GitHub API rate limit exceeded for the token from %s; it resets at %s", c.token.Source(), resets) } - return releases, nil + return fmt.Errorf("GitHub API rate limit exceeded; it resets at %s. %s to authenticate and get a higher limit", resets, token.AuthenticationHint) } diff --git a/pkg/releases/installed.go b/pkg/releases/installed.go new file mode 100644 index 0000000..d790e85 --- /dev/null +++ b/pkg/releases/installed.go @@ -0,0 +1,35 @@ +package releases + +import ( + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +// SortInstalled returns the names of installed versions in the order +// list shows them: names that are semver versions, highest first, then +// the names that are not, alphabetically. A name is a semver version by +// the same rule that decides which tags Select considers. Versions that +// semver ranks equal, which differ only in build metadata, are ordered +// alphabetically. +func SortInstalled(names []string) []string { + sorted := slices.Clone(names) + slices.SortFunc(sorted, func(a, b string) int { + aIsSemver, bIsSemver := isSemver(a), isSemver(b) + switch { + case aIsSemver && bIsSemver: + if c := semver.Compare(semverForm(b), semverForm(a)); c != 0 { + return c + } + return strings.Compare(a, b) + case aIsSemver: + return -1 + case bIsSemver: + return 1 + default: + return strings.Compare(a, b) + } + }) + return sorted +} diff --git a/pkg/releases/installed_test.go b/pkg/releases/installed_test.go new file mode 100644 index 0000000..77c6f77 --- /dev/null +++ b/pkg/releases/installed_test.go @@ -0,0 +1,84 @@ +package releases_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +func TestSortInstalledOrdersSemverNamesHighestFirst(t *testing.T) { + tests := []struct { + name string + names []string + want []string + }{ + { + name: "numeric minor versions", + names: []string{"3.9.0", "3.10.0", "3.8.1"}, + want: []string{"3.10.0", "3.9.0", "3.8.1"}, + }, + { + name: "numeric pre-release identifiers", + names: []string{"4.0.0-alpha.2", "4.0.0-alpha.13", "4.0.0-alpha.9"}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.9", "4.0.0-alpha.2"}, + }, + { + name: "a release above its pre-releases", + names: []string{"3.8.0-rc.13", "3.7.4", "3.8.0", "3.8.0-rc.2"}, + want: []string{"3.8.0", "3.8.0-rc.13", "3.8.0-rc.2", "3.7.4"}, + }, + { + name: "versions that differ only in build metadata, alphabetically", + names: []string{"3.8.0+build.2", "3.9.0", "3.8.0+build.1"}, + want: []string{"3.9.0", "3.8.0+build.1", "3.8.0+build.2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, releases.SortInstalled(tt.names)) + }) + } +} + +func TestSortInstalledPutsNamesThatAreNotSemverLastAlphabetically(t *testing.T) { + tests := []struct { + name string + names []string + want []string + }{ + { + name: "after the semver names", + names: []string{"latest", "3.9.0", "my-build", "3.10.0"}, + want: []string{"3.10.0", "3.9.0", "latest", "my-build"}, + }, + { + name: "shorthands, prefixes and leading zeros are not semver", + names: []string{ + "v3.7.0", "3.8", "3.08.0", "3.7.0", "3", + }, + want: []string{"3.7.0", "3", "3.08.0", "3.8", "v3.7.0"}, + }, + { + name: "only names that are not semver", + names: []string{"zeta", "alpha", "Beta"}, + want: []string{"Beta", "alpha", "zeta"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, releases.SortInstalled(tt.names)) + }) + } +} + +func TestSortInstalledLeavesItsInputAlone(t *testing.T) { + names := []string{"3.7.0", "latest", "3.8.0"} + + _ = releases.SortInstalled(names) + + assert.Equal(t, []string{"3.7.0", "latest", "3.8.0"}, names) +} diff --git a/pkg/releases/latest.go b/pkg/releases/latest.go new file mode 100644 index 0000000..efc917b --- /dev/null +++ b/pkg/releases/latest.go @@ -0,0 +1,13 @@ +package releases + +// Latest returns the highest stable release in all by semver. It follows +// the rules Select follows without pre-releases: drafts, pre-releases and +// tags that are not semver never count. Neither the order of all nor +// publish times matter. ok is false when all holds no stable release. +func Latest(all []Release) (latest Release, ok bool) { + chosen, _ := Select(all, SelectOptions{Limit: 1, Order: BySemver}) + if len(chosen) == 0 { + return Release{}, false + } + return chosen[0], true +} diff --git a/pkg/releases/latest_test.go b/pkg/releases/latest_test.go new file mode 100644 index 0000000..0fca392 --- /dev/null +++ b/pkg/releases/latest_test.go @@ -0,0 +1,94 @@ +package releases_test + +import ( + "fmt" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +func TestLatestIsTheHighestStableReleaseBySemver(t *testing.T) { + // Twelve release candidates of 3.8.0, newest first, as GitHub lists + // them during a release-candidate cycle. + var candidates []releases.Release + for rc := 12; rc >= 1; rc-- { + candidates = append(candidates, releases.Release{ + Tag: fmt.Sprintf("3.8.0-rc.%d", rc), + PublishedAt: day(time.August, 14+rc), + GitHubPrerelease: true, + }) + } + + tests := []struct { + name string + all []releases.Release + want string + }{ + { + name: "ordered by semver, not as strings", + all: []releases.Release{stable("3.9.0"), stable("3.10.0"), stable("3.8.0")}, + want: "3.10.0", + }, + { + name: "after more than ten consecutive pre-releases", + all: slices.Concat(candidates, []releases.Release{stable("3.7.3"), stable("3.7.2")}), + want: "3.7.3", + }, + { + name: "a backport published after a higher release", + all: []releases.Release{ + {Tag: "3.7.4", PublishedAt: day(time.September, 10)}, + {Tag: "3.8.0", PublishedAt: day(time.August, 26)}, + {Tag: "3.7.3", PublishedAt: day(time.July, 1)}, + }, + want: "3.8.0", + }, + { + name: "pre-release tags that GitHub never flagged", + all: []releases.Release{stable("3.9.0-rc.1"), stable("2.6.0-alpha.30"), stable("2.5.0")}, + want: "2.5.0", + }, + { + name: "a release GitHub flags as a pre-release", + all: []releases.Release{ + {Tag: "3.9.0", PublishedAt: day(time.September, 1), GitHubPrerelease: true}, + stable("3.8.0"), + }, + want: "3.8.0", + }, + { + name: "drafts and tags that are not semver", + all: []releases.Release{ + {Tag: "3.9.0", Draft: true}, + stable("latest"), + stable("v4.0.0"), + stable("4.0"), + stable("3.8.0"), + }, + want: "3.8.0", + }, + { + name: "no stable release", + all: slices.Concat(candidates, []releases.Release{ + {Tag: "3.7.0", Draft: true}, + stable("3.6.0-beta.1"), + }), + }, + { + name: "no release at all", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + latest, ok := releases.Latest(tt.all) + + assert.Equal(t, tt.want != "", ok) + assert.Equal(t, tt.want, latest.Tag) + }) + } +} diff --git a/pkg/releases/releases.go b/pkg/releases/releases.go new file mode 100644 index 0000000..7bef490 --- /dev/null +++ b/pkg/releases/releases.go @@ -0,0 +1,95 @@ +// Package releases holds the rules about Educates releases: which ones +// take part in a listing, which ones are pre-releases, and how they are +// ordered. It does no I/O. +package releases + +import ( + "slices" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// Release is a GitHub release of Educates. +type Release struct { + // Tag is the release's tag as published, such as "3.8.0". + Tag string + // PublishedAt is when the release was published. + PublishedAt time.Time + // GitHubPrerelease is GitHub's pre-release flag. + GitHubPrerelease bool + // Draft is GitHub's draft flag. + Draft bool +} + +// SortOrder is the order Select returns releases in. +type SortOrder int + +const ( + // BySemver orders releases by version, highest first. + BySemver SortOrder = iota + // ByPublishTime orders releases by publish time, newest first. + ByPublishTime +) + +// All is the Limit that chooses every candidate. +const All = 0 + +// SelectOptions are the choices Select makes. +type SelectOptions struct { + // IncludePrereleases adds pre-releases to the candidates. + IncludePrereleases bool + // Limit is the most releases to choose. All, or any value below 1, + // chooses every candidate. + Limit int + // Order is the order of the chosen releases. + Order SortOrder +} + +// Select chooses releases from all. It returns the chosen releases, in +// the order opts names, and the number of candidates they were chosen +// from. +func Select(all []Release, opts SelectOptions) (chosen []Release, candidates int) { + for _, r := range all { + if r.Draft || !isSemver(r.Tag) { + continue + } + if r.isPrerelease() && !opts.IncludePrereleases { + continue + } + chosen = append(chosen, r) + } + slices.SortStableFunc(chosen, func(a, b Release) int { + if opts.Order == ByPublishTime { + return b.PublishedAt.Compare(a.PublishedAt) + } + return semver.Compare(semverForm(b.Tag), semverForm(a.Tag)) + }) + + candidates = len(chosen) + if opts.Limit > 0 && opts.Limit < candidates { + chosen = chosen[:opts.Limit] + } + return chosen, candidates +} + +// isPrerelease reports whether r is a pre-release: its tag has a semver +// pre-release part, or GitHub flags it. +func (r Release) isPrerelease() bool { + return r.GitHubPrerelease || semver.Prerelease(semverForm(r.Tag)) != "" +} + +// semverForm returns tag in the form golang.org/x/mod/semver reads, which +// needs a "v" prefix that Educates tags do not have. +func semverForm(tag string) string { + return "v" + tag +} + +// isSemver reports whether tag is a full semver version, such as "3.8.0" +// or "4.0.0-alpha.13". The shorthands golang.org/x/mod/semver accepts, +// such as "3.8", are not. +func isSemver(tag string) bool { + v := semverForm(tag) + return semver.Canonical(v) == strings.TrimSuffix(v, semver.Build(v)) +} diff --git a/pkg/releases/releases_test.go b/pkg/releases/releases_test.go new file mode 100644 index 0000000..1e78ad1 --- /dev/null +++ b/pkg/releases/releases_test.go @@ -0,0 +1,275 @@ +package releases_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/educates/educatesenv/pkg/releases" +) + +// day returns noon UTC on the given day of 2026, so tests can order +// releases by publish time without spelling out timestamps. +func day(month time.Month, d int) time.Time { + return time.Date(2026, month, d, 12, 0, 0, 0, time.UTC) +} + +// stable returns a published release that GitHub does not flag. +func stable(tag string) releases.Release { + return releases.Release{Tag: tag, PublishedAt: day(time.January, 1)} +} + +// tags returns the tags of rs, in order. +func tags(rs []releases.Release) []string { + out := make([]string, 0, len(rs)) + for _, r := range rs { + out = append(out, r.Tag) + } + return out +} + +func TestSelectLeavesOutPrereleasesUnlessIncluded(t *testing.T) { + all := []releases.Release{ + stable("3.8.0"), + {Tag: "3.8.0-rc.2", PublishedAt: day(time.August, 14), GitHubPrerelease: true}, + // Older pre-releases that GitHub never flagged. + stable("2.6.0-alpha.30"), + stable("2.6.0-beta.1"), + // GitHub flags it, though the tag has no pre-release part. + {Tag: "2.5.9", PublishedAt: day(time.January, 2), GitHubPrerelease: true}, + stable("2.5.0"), + } + + tests := []struct { + name string + includePrereleases bool + want []string + }{ + { + name: "stable only", + want: []string{"3.8.0", "2.5.0"}, + }, + { + name: "with pre-releases", + includePrereleases: true, + want: []string{"3.8.0", "3.8.0-rc.2", "2.6.0-beta.1", "2.6.0-alpha.30", "2.5.9", "2.5.0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chosen, candidates := releases.Select(all, releases.SelectOptions{ + IncludePrereleases: tt.includePrereleases, + Limit: releases.All, + }) + + assert.Equal(t, tt.want, tags(chosen)) + assert.Equal(t, len(tt.want), candidates) + }) + } +} + +func TestSelectNeverChoosesDraftsOrTagsThatAreNotSemver(t *testing.T) { + all := []releases.Release{ + {Tag: "3.9.0", Draft: true}, + {Tag: "3.9.0-rc.1", Draft: true, GitHubPrerelease: true}, + stable("3.8.0"), + stable("latest"), + stable("v3.7.0"), + stable("3.8"), + stable("3.08.0"), + stable(""), + stable("3.7.0-rc.1"), + stable("3.6.0"), + } + + chosen, candidates := releases.Select(all, releases.SelectOptions{ + IncludePrereleases: true, + Limit: releases.All, + }) + + assert.Equal(t, []string{"3.8.0", "3.7.0-rc.1", "3.6.0"}, tags(chosen)) + assert.Equal(t, 3, candidates) +} + +func TestSelectByPublishTimeOrdersNewestFirst(t *testing.T) { + at := func(d, hour int) time.Time { + return time.Date(2026, time.August, d, hour, 0, 0, 0, time.UTC) + } + all := []releases.Release{ + {Tag: "4.0.0-alpha.12", PublishedAt: at(10, 9)}, + {Tag: "3.8.0-rc.12", PublishedAt: at(25, 9)}, + {Tag: "3.8.1-rc.2", PublishedAt: at(28, 9)}, + {Tag: "3.8.0", PublishedAt: at(26, 18)}, + {Tag: "3.8.0-rc.13", PublishedAt: at(25, 17)}, + {Tag: "4.0.0-alpha.13", PublishedAt: at(27, 9)}, + } + + chosen, _ := releases.Select(all, releases.SelectOptions{ + IncludePrereleases: true, + Limit: releases.All, + Order: releases.ByPublishTime, + }) + + assert.Equal(t, []string{ + "3.8.1-rc.2", + "4.0.0-alpha.13", + "3.8.0", + "3.8.0-rc.13", + "3.8.0-rc.12", + "4.0.0-alpha.12", + }, tags(chosen)) +} + +func TestSelectCombinesPrereleasesLimitAndOrder(t *testing.T) { + // Listed in creation order, newest first, as GitHub lists them. + all := []releases.Release{ + {Tag: "3.7.4", PublishedAt: day(time.September, 2)}, + {Tag: "3.8.1-rc.2", PublishedAt: day(time.August, 28), GitHubPrerelease: true}, + {Tag: "4.0.0-alpha.13", PublishedAt: day(time.August, 27), GitHubPrerelease: true}, + {Tag: "3.8.0", PublishedAt: day(time.August, 26)}, + {Tag: "3.8.0-rc.13", PublishedAt: day(time.August, 25), GitHubPrerelease: true}, + {Tag: "3.7.3", PublishedAt: day(time.July, 15)}, + {Tag: "4.0.0-alpha.2", PublishedAt: day(time.July, 1), GitHubPrerelease: true}, + {Tag: "3.6.0", PublishedAt: day(time.May, 1)}, + } + + tests := []struct { + name string + opts releases.SelectOptions + want []string + candidates int + }{ + { + name: "stable, 3, by semver", + opts: releases.SelectOptions{Limit: 3, Order: releases.BySemver}, + want: []string{"3.8.0", "3.7.4", "3.7.3"}, + candidates: 4, + }, + { + name: "stable, all, by semver", + opts: releases.SelectOptions{Limit: releases.All, Order: releases.BySemver}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "stable, 3, by publish time", + opts: releases.SelectOptions{Limit: 3, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.0", "3.7.3"}, + candidates: 4, + }, + { + name: "stable, all, by publish time", + opts: releases.SelectOptions{Limit: releases.All, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.0", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "with pre-releases, 3, by semver", + opts: releases.SelectOptions{IncludePrereleases: true, Limit: 3, Order: releases.BySemver}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2"}, + candidates: 8, + }, + { + name: "with pre-releases, all, by semver", + opts: releases.SelectOptions{IncludePrereleases: true, Limit: releases.All, Order: releases.BySemver}, + want: []string{ + "4.0.0-alpha.13", "4.0.0-alpha.2", "3.8.1-rc.2", "3.8.0", + "3.8.0-rc.13", "3.7.4", "3.7.3", "3.6.0", + }, + candidates: 8, + }, + { + name: "with pre-releases, 3, by publish time", + opts: releases.SelectOptions{IncludePrereleases: true, Limit: 3, Order: releases.ByPublishTime}, + want: []string{"3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13"}, + candidates: 8, + }, + { + name: "with pre-releases, all, by publish time", + opts: releases.SelectOptions{IncludePrereleases: true, Limit: releases.All, Order: releases.ByPublishTime}, + want: []string{ + "3.7.4", "3.8.1-rc.2", "4.0.0-alpha.13", "3.8.0", + "3.8.0-rc.13", "3.7.3", "4.0.0-alpha.2", "3.6.0", + }, + candidates: 8, + }, + { + name: "a limit above the number of candidates", + opts: releases.SelectOptions{Limit: 10}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "a limit equal to the number of candidates", + opts: releases.SelectOptions{Limit: 4}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + { + name: "a negative limit", + opts: releases.SelectOptions{Limit: -1}, + want: []string{"3.8.0", "3.7.4", "3.7.3", "3.6.0"}, + candidates: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chosen, candidates := releases.Select(all, tt.opts) + + assert.Equal(t, tt.want, tags(chosen)) + assert.Equal(t, tt.candidates, candidates) + }) + } +} + +func TestSelectLeavesItsInputAlone(t *testing.T) { + all := []releases.Release{stable("3.7.0"), stable("3.9.0-rc.1"), stable("3.8.0")} + + _, _ = releases.Select(all, releases.SelectOptions{Limit: 1}) + + assert.Equal(t, []string{"3.7.0", "3.9.0-rc.1", "3.8.0"}, tags(all)) +} + +func TestSelectOrdersBySemverHighestFirst(t *testing.T) { + tests := []struct { + name string + tags []string + want []string + }{ + { + name: "numeric minor versions", + tags: []string{"3.9.0", "3.10.0", "3.8.1"}, + want: []string{"3.10.0", "3.9.0", "3.8.1"}, + }, + { + name: "numeric pre-release identifiers", + tags: []string{"4.0.0-alpha.2", "4.0.0-alpha.13", "4.0.0-alpha.9"}, + want: []string{"4.0.0-alpha.13", "4.0.0-alpha.9", "4.0.0-alpha.2"}, + }, + { + name: "a release above its pre-releases", + tags: []string{"3.8.0-rc.13", "3.8.0", "3.8.0-rc.2", "3.7.4"}, + want: []string{"3.8.0", "3.8.0-rc.13", "3.8.0-rc.2", "3.7.4"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var all []releases.Release + for _, tag := range tt.tags { + all = append(all, stable(tag)) + } + + chosen, _ := releases.Select(all, releases.SelectOptions{ + IncludePrereleases: true, + Limit: releases.All, + Order: releases.BySemver, + }) + + assert.Equal(t, tt.want, tags(chosen)) + }) + } +} diff --git a/pkg/token/token.go b/pkg/token/token.go new file mode 100644 index 0000000..72d5ddf --- /dev/null +++ b/pkg/token/token.go @@ -0,0 +1,130 @@ +// Package token finds the GitHub token educatesenv authenticates with. It +// reads the token from the environment, from the gh CLI or from the config +// file, and never stores one. +package token + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// tokenSource is where a token came from. +type tokenSource int + +const ( + notSet tokenSource = iota + educatesGitHubToken + ghToken + githubToken + ghAuthToken + configFile +) + +// sourceLabels name each source in config view and in error messages. +var sourceLabels = map[tokenSource]string{ + notSet: "not set", + educatesGitHubToken: "EDUCATES_GITHUB_TOKEN", + ghToken: "GH_TOKEN", + githubToken: "GITHUB_TOKEN", + ghAuthToken: "gh auth token", + configFile: "config file (deprecated)", +} + +// tokenVariables are the environment variables that hold a token, in the +// order they are checked. +var tokenVariables = []struct { + variable string + source tokenSource +}{ + {"EDUCATES_GITHUB_TOKEN", educatesGitHubToken}, + {"GH_TOKEN", ghToken}, + {"GITHUB_TOKEN", githubToken}, +} + +// AuthenticationHint tells the user how to give educatesenv a token. It +// starts a sentence that each message finishes in its own way. +const AuthenticationHint = "Set GH_TOKEN or run 'gh auth login'" + +// configFileWarning is the warning for a token from the config file. Its +// verb is the file's path. +const configFileWarning = "Warning: github.token in %s is deprecated, and a later release will stop reading it. " + + AuthenticationHint + " instead, and remove the token from that file.\n" + +// Token is a GitHub token and the source it came from. Formatting a Token +// with %s or %v shows its source, never any character of the token. +type Token struct { + value string + source tokenSource +} + +// Value returns the token, or "" when no source has one. +func (t Token) Value() string { + return t.value +} + +// IsSet reports whether a source had a token. +func (t Token) IsSet() bool { + return t.value != "" +} + +// Source returns the label of the token's source: EDUCATES_GITHUB_TOKEN, +// GH_TOKEN, GITHUB_TOKEN, gh auth token, config file (deprecated), or not +// set. +func (t Token) Source() string { + return sourceLabels[t.source] +} + +// String returns how config view shows the token: and +// the like, or . +func (t Token) String() string { + switch t.source { + case notSet: + return "" + case configFile: + return "" + default: + return "" + } +} + +// Resolve returns the token from the first of these sources that has one: +// +// 1. EDUCATES_GITHUB_TOKEN +// 2. GH_TOKEN +// 3. GITHUB_TOKEN +// 4. the output of `gh auth token --hostname github.com`, when gh is on +// the PATH +// 5. fileToken, the github.token value of the config file at file +// +// A source whose token is empty has none. A token from the config file is +// deprecated, so Resolve writes a warning that names the file to warnings +// when it returns one. +func Resolve(file, fileToken string, warnings io.Writer) Token { + for _, e := range tokenVariables { + if value := os.Getenv(e.variable); value != "" { + return Token{value: value, source: e.source} + } + } + if value := fromGH(); value != "" { + return Token{value: value, source: ghAuthToken} + } + if fileToken != "" { + _, _ = fmt.Fprintf(warnings, configFileWarning, file) + return Token{value: fileToken, source: configFile} + } + return Token{} +} + +// fromGH returns the token gh holds for github.com, or "" when gh is not on +// the PATH, fails, or prints nothing. gh's own output on stderr is +// discarded. +func fromGH() string { + out, err := exec.Command("gh", "auth", "token", "--hostname", "github.com").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/version/installed.go b/pkg/version/installed.go new file mode 100644 index 0000000..5e3b227 --- /dev/null +++ b/pkg/version/installed.go @@ -0,0 +1,56 @@ +package version + +import ( + "os" + "strings" + + "github.com/educates/educatesenv/pkg/platform" +) + +// Installed returns the versions installed in the bin directory, in no +// particular order, and the active one, the version the educates symlink +// points at. The active version is one of versions, Develop when +// development mode is enabled and the symlink points at the development +// binary, or "" when no version is active. Installed fails +// when the bin directory cannot be read; a missing bin directory gives an +// error that wraps fs.ErrNotExist. +func (m *Manager) Installed() (versions []string, active string, err error) { + entries, err := os.ReadDir(m.config.Local.Dir) + if err != nil { + return nil, "", err + } + for _, entry := range entries { + if entry.IsDir() || entry.Name() == activeLinkName { + continue + } + if version, ok := strings.CutPrefix(entry.Name(), platform.BinaryPrefix); ok { + versions = append(versions, version) + } + } + return versions, m.activeVersion(versions), nil +} + +// activeVersion returns the version among installed whose binary the +// educates symlink resolves to, or Develop when development mode is +// enabled and the symlink resolves to the development binary. It returns +// "" when the symlink resolves to none of them, or does not resolve. +func (m *Manager) activeVersion(installed []string) string { + active, err := os.Stat(m.activeLink()) + if err != nil { + return "" + } + isActive := func(path string) bool { + binary, err := os.Stat(path) + return err == nil && os.SameFile(active, binary) + } + + for _, version := range installed { + if isActive(m.binaryPath(version)) { + return version + } + } + if m.config.Development.Enabled && m.config.Development.BinaryLocation != "" && isActive(m.config.Development.BinaryLocation) { + return Develop + } + return "" +} diff --git a/pkg/version/latest.go b/pkg/version/latest.go new file mode 100644 index 0000000..ec990fa --- /dev/null +++ b/pkg/version/latest.go @@ -0,0 +1,30 @@ +package version + +import ( + "fmt" + + "github.com/educates/educatesenv/pkg/releases" +) + +// Latest is the version name that InstallVersion resolves to the highest +// stable release. +const Latest = "latest" + +// ResolveLatest returns the tag of the highest stable release by semver, +// among every release of the repository. +func (m *Manager) ResolveLatest() (string, error) { + gh, err := m.github() + if err != nil { + return "", err + } + published, err := gh.ListReleases() + if err != nil { + return "", err + } + + latest, ok := releases.Latest(published) + if !ok { + return "", fmt.Errorf("no stable releases found in %s/%s. Run 'educatesenv list-remote --pre-releases' to see pre-releases", m.config.Github.Org, m.config.Github.Repository) + } + return latest.Tag, nil +} diff --git a/pkg/version/manager.go b/pkg/version/manager.go index 12a8ffb..a969823 100644 --- a/pkg/version/manager.go +++ b/pkg/version/manager.go @@ -1,30 +1,110 @@ package version import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "io" + "io/fs" + "net" "net/http" "os" "path/filepath" "runtime" + "strconv" "strings" + "time" "github.com/educates/educatesenv/pkg/config" "github.com/educates/educatesenv/pkg/github" "github.com/educates/educatesenv/pkg/platform" ) +// Timeouts of the downloads of release assets. The first three each bound a +// step that waits on the server before any of the body arrives, and +// downloadIdleTimeout bounds each wait for more of the body. Nothing bounds +// the whole transfer, so a slow download that keeps making progress +// finishes. +const ( + connectTimeout = 30 * time.Second + tlsHandshakeTimeout = 10 * time.Second + responseHeaderTimeout = 30 * time.Second + downloadIdleTimeout = 60 * time.Second +) + // Manager handles version-related operations type Manager struct { - config *config.Config - github *github.Client + config *config.Config + github func() (*github.Client, error) + http *http.Client + downloadIdleTimeout time.Duration + out io.Writer + errOut io.Writer +} + +// Option changes how New builds a Manager. +type Option func(*Manager) + +// WithDownloadIdleTimeout makes a download fail when no data arrives for +// timeout. The default is 60 seconds, and a zero timeout keeps it. +func WithDownloadIdleTimeout(timeout time.Duration) Option { + return func(m *Manager) { + if timeout > 0 { + m.downloadIdleTimeout = timeout + } + } } -// New creates a new version manager -func New(cfg *config.Config, gh *github.Client) *Manager { - return &Manager{ - config: cfg, - github: gh, +// New creates a new version manager that reports progress to out and +// warnings to errOut. gh returns the GitHub client, and the manager calls +// it only when an operation needs GitHub. +func New(cfg *config.Config, gh func() (*github.Client, error), out, errOut io.Writer, opts ...Option) *Manager { + m := &Manager{ + config: cfg, + github: gh, + http: newDownloadClient(), + downloadIdleTimeout: downloadIdleTimeout, + out: out, + errOut: errOut, + } + for _, opt := range opts { + opt(m) + } + return m +} + +// Develop is the version name that stands for the development binary. +const Develop = "develop" + +// activeLinkName is the name of the symlink in the bin directory that +// points at the active version's binary, which then runs as educates. +const activeLinkName = "educates" + +// activeLink returns the path of the educates symlink. +func (m *Manager) activeLink() string { + return filepath.Join(m.config.Local.Dir, activeLinkName) +} + +// binaryPath returns the path of the binary of the installed version. +func (m *Manager) binaryPath(version string) string { + return filepath.Join(m.config.Local.Dir, platform.BinaryPrefix+version) +} + +// newDownloadClient returns the HTTP client that downloads release assets. +// It times out on connecting, on the TLS handshake and on waiting for the +// response headers, and sets no limit on the whole transfer. +func newDownloadClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext, + TLSHandshakeTimeout: tlsHandshakeTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + ForceAttemptHTTP2: true, + }, } } @@ -34,7 +114,7 @@ func (m *Manager) ValidateDevelopmentMode() error { return nil } - symlinkPath := filepath.Join(m.config.Local.Dir, "educates") + symlinkPath := m.activeLink() isDev, err := m.isDevSymlink(symlinkPath) if err != nil { return fmt.Errorf("failed to validate development mode: %w", err) @@ -65,18 +145,22 @@ func (m *Manager) isDevSymlink(symlinkPath string) (bool, error) { target = filepath.Join(filepath.Dir(symlinkPath), target) } - // A symlink is considered a development symlink if: - // 1. Development mode is disabled AND - // 2. The target is outside the managed bin directory OR doesn't have the expected binary prefix - return !strings.HasPrefix(target, m.config.Local.Dir) || !strings.HasPrefix(filepath.Base(target), platform.BinaryPrefix), nil + // A symlink is a development symlink when its target is not directly + // in the managed bin directory, or lacks the binary prefix. The + // directories are compared as cleaned paths, so a sibling directory + // whose name starts with the bin directory's counts as outside. + // filepath.Dir returns a cleaned path. + inBinDir := filepath.Dir(target) == filepath.Clean(m.config.Local.Dir) + return !inBinDir || !strings.HasPrefix(filepath.Base(target), platform.BinaryPrefix), nil } -// UseVersion sets a version as active +// UseVersion sets a version as active. When the version's binary is +// missing, the error wraps fs.ErrNotExist. func (m *Manager) UseVersion(version string) error { - symlinkPath := filepath.Join(m.config.Local.Dir, "educates") + symlinkPath := m.activeLink() // Handle development version - if version == "develop" { + if version == Develop { if !m.config.Development.Enabled { return fmt.Errorf("development mode is not enabled. Enable it in the config file by setting development.enabled to true") } @@ -87,7 +171,7 @@ func (m *Manager) UseVersion(version string) error { } // Handle regular version - binaryPath := filepath.Join(m.config.Local.Dir, fmt.Sprintf("%s%s", platform.BinaryPrefix, version)) + binaryPath := m.binaryPath(version) err := m.createSymlink(binaryPath, symlinkPath) if err != nil { return fmt.Errorf("%w", err) @@ -107,26 +191,36 @@ func (m *Manager) GetPlatformBinaryName() (string, error) { return platform.GetPlatformBinaryName(os, arch), nil } -// InstallVersion installs a specific version of educates +// InstallVersion installs a specific version of educates. Latest installs +// the highest stable release, and says which version that is. func (m *Manager) InstallVersion(version string, force bool, activate bool) error { + if version == Latest { + resolved, err := m.ResolveLatest() + if err != nil { + return err + } + _, _ = fmt.Fprintf(m.out, "Resolved latest to %s\n", resolved) + version = resolved + } + binDir := m.config.Local.Dir if err := os.MkdirAll(binDir, 0o755); err != nil { return fmt.Errorf("failed to create bin directory %s: %w", binDir, err) } // Check if version already exists - binaryPath := filepath.Join(binDir, fmt.Sprintf("%s%s", platform.BinaryPrefix, version)) + binaryPath := m.binaryPath(version) _, err := os.Stat(binaryPath) versionExists := err == nil // Handle installation if versionExists && !force { - fmt.Printf("Version %s is already installed.\n", version) + _, _ = fmt.Fprintf(m.out, "Version %s is already installed.\n", version) } else { if versionExists { - fmt.Printf("Reinstalling version %s...\n", version) + _, _ = fmt.Fprintf(m.out, "Reinstalling version %s...\n", version) } else { - fmt.Printf("Installing version %s...\n", version) + _, _ = fmt.Fprintf(m.out, "Installing version %s...\n", version) } assetName, err := m.GetPlatformBinaryName() @@ -134,19 +228,24 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro return fmt.Errorf("failed to determine platform binary name: %w", err) } - downloadURL, err := m.github.GetReleaseAssetURL(version, assetName) + gh, err := m.github() + if err != nil { + return err + } + assets, err := gh.GetReleaseAssets(version, assetName) if err != nil { return err // Pass through the user-friendly error from GitHub client } - fmt.Printf("Downloading %s...\n", downloadURL) - if err := m.downloadFile(downloadURL, binaryPath); err != nil { - return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) + releasedSum, err := m.releasedChecksum(version, assetName, assets.ChecksumsURL) + if err != nil { + return err } - if err := os.Chmod(binaryPath, 0o755); err != nil { - return fmt.Errorf("failed to set executable permissions on %s: %w", binaryPath, err) + + if err := m.installBinary(assetName, assets.BinaryURL, releasedSum, binaryPath); err != nil { + return err } - fmt.Printf("educates %s installed successfully.\n", version) + _, _ = fmt.Fprintf(m.out, "educates %s installed successfully.\n", version) } // Handle activation if requested @@ -154,7 +253,7 @@ func (m *Manager) InstallVersion(version string, force bool, activate bool) erro if err := m.UseVersion(version); err != nil { return fmt.Errorf("installation succeeded but failed to set version %s as active: %w", version, err) } - fmt.Printf("educates %s is now active.\n", version) + _, _ = fmt.Fprintf(m.out, "educates %s is now active.\n", version) } return nil @@ -165,7 +264,7 @@ func (m *Manager) createSymlink(source, target string) error { // Check if source exists if _, err := os.Stat(source); err != nil { if os.IsNotExist(err) { - return fmt.Errorf("binary not found at %s", source) + return fmt.Errorf("binary not found at %s: %w", source, fs.ErrNotExist) } return fmt.Errorf("failed to check binary: %w", err) } @@ -195,32 +294,129 @@ func (m *Manager) createSymlink(source, target string) error { return nil } -// downloadFile downloads a file from a URL to a local path -func (m *Manager) downloadFile(url, outPath string) error { - resp, err := http.Get(url) +// releasedChecksum returns the SHA-256 checksum that the checksums.txt of +// the release of version lists for assetName. When the release has no +// checksums.txt, it warns that the download cannot be verified and returns +// "". +func (m *Manager) releasedChecksum(version, assetName, checksumsURL string) (string, error) { + if checksumsURL == "" { + _, _ = fmt.Fprintf(m.errOut, "Warning: release %s has no %s, so %s is installed without verifying its checksum.\n", version, github.ChecksumsAssetName, assetName) + return "", nil + } + + var checksums bytes.Buffer + if err := m.download(checksumsURL, &checksums); err != nil { + return "", fmt.Errorf("failed to download %s (check your internet connection and try again): %w", github.ChecksumsAssetName, err) + } + sum, ok := findChecksum(checksums.String(), assetName) + if !ok { + return "", fmt.Errorf("%s has no entry for %s, so the binary cannot be verified as the one that was released", github.ChecksumsAssetName, assetName) + } + return sum, nil +} + +// findChecksum returns the checksum that checksums, in the format sha256sum +// writes, lists for name. +func findChecksum(checksums, name string) (string, bool) { + for line := range strings.Lines(checksums) { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + return strings.ToLower(fields[0]), true + } + } + return "", false +} + +// installBinary downloads the binary assetName from url into a temporary +// file next to binaryPath, checks its SHA-256 checksum against releasedSum +// unless releasedSum is empty, makes it executable and only then renames it +// to binaryPath, replacing any binary already there. The temporary file is +// removed when any step fails, so binaryPath holds either the previous +// binary or the complete, verified new one. +func (m *Manager) installBinary(assetName, url, releasedSum, binaryPath string) (err error) { + dir := filepath.Dir(binaryPath) + // The leading dot keeps the temporary file out of the installed versions. + tmp, err := os.CreateTemp(dir, "."+filepath.Base(binaryPath)+"-*.download") if err != nil { - return err + return fmt.Errorf("failed to create a temporary file in %s: %w", dir, err) } defer func() { - if cerr := resp.Body.Close(); cerr != nil && err == nil { - err = fmt.Errorf("error closing response body: %w", cerr) + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmp.Name()) } }() - if resp.StatusCode != 200 { - return fmt.Errorf("failed to download file: %s", resp.Status) + _, _ = fmt.Fprintf(m.out, "Downloading %s...\n", url) + hash := sha256.New() + if err := m.download(url, io.MultiWriter(tmp, hash)); err != nil { + return fmt.Errorf("failed to download binary (check your internet connection and try again): %w", err) + } + + if releasedSum != "" { + if sum := hex.EncodeToString(hash.Sum(nil)); sum != releasedSum { + return fmt.Errorf("checksum mismatch for %s: %s lists %s, but the download has %s", assetName, github.ChecksumsAssetName, releasedSum, sum) + } + _, _ = fmt.Fprintf(m.out, "Checksum verified against %s.\n", github.ChecksumsAssetName) + } + + if err := tmp.Chmod(0o755); err != nil { + return fmt.Errorf("failed to set executable permissions on %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to write %s: %w", tmp.Name(), err) } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to write %s: %w", tmp.Name(), err) + } + if err := os.Rename(tmp.Name(), binaryPath); err != nil { + return fmt.Errorf("failed to move the downloaded binary to %s: %w", binaryPath, err) + } + return nil +} - out, err := os.Create(outPath) +// download writes the body of a GET of url to w. It fails when the body +// stalls: when a read waits longer than the download idle timeout for data. +func (m *Manager) download(url string, w io.Writer) error { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } - defer func() { - if cerr := out.Close(); cerr != nil && err == nil { - err = fmt.Errorf("error closing output file: %w", cerr) - } - }() + resp, err := m.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() - _, err = io.Copy(out, resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: %s", url, resp.Status) + } + + seconds := strconv.FormatFloat(m.downloadIdleTimeout.Seconds(), 'f', -1, 64) + stalled := fmt.Errorf("download stalled: no data arrived for %s seconds", seconds) + timer := time.AfterFunc(m.downloadIdleTimeout, func() { cancel(stalled) }) + defer timer.Stop() + + _, err = io.Copy(w, &idleTimeoutReader{r: resp.Body, timeout: m.downloadIdleTimeout, timer: timer}) + if err != nil && errors.Is(context.Cause(ctx), stalled) { + return stalled + } return err } + +// idleTimeoutReader reads from r, and runs timer's function when a read +// waits longer than timeout for data. +type idleTimeoutReader struct { + r io.Reader + timeout time.Duration + timer *time.Timer +} + +func (r *idleTimeoutReader) Read(p []byte) (int, error) { + r.timer.Reset(r.timeout) + defer r.timer.Stop() + return r.r.Read(p) +} diff --git a/pkg/version/manager_test.go b/pkg/version/manager_test.go deleted file mode 100644 index bb9147a..0000000 --- a/pkg/version/manager_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package version - -import ( - "os" - "path/filepath" - "testing" - - "github.com/educates/educatesenv/pkg/config" - "github.com/educates/educatesenv/pkg/github" - "github.com/stretchr/testify/assert" -) - -func setupTestManager(t *testing.T) (*Manager, string, func()) { - // Create temp directory - tmpDir, err := os.MkdirTemp("", "educatesenv-test") - assert.NoError(t, err) - - // Create config - cfg := &config.Config{ - Github: config.GithubConfig{ - Org: "testorg", - Repository: "testrepo", - Token: "testtoken", - }, - Local: config.LocalConfig{ - Dir: tmpDir, - }, - Development: config.DevelopmentConfig{ - Enabled: false, - BinaryLocation: "", - }, - } - - // Create GitHub client - gh := github.New(cfg) - - // Create manager - manager := New(cfg, gh) - - cleanup := func() { - err := os.RemoveAll(tmpDir) - assert.NoError(t, err) - } - - return manager, tmpDir, cleanup -} - -func TestValidateDevelopmentMode(t *testing.T) { - manager, tmpDir, cleanup := setupTestManager(t) - defer cleanup() - - // Test with no symlink - err := manager.ValidateDevelopmentMode() - assert.NoError(t, err) - - // Test with regular symlink - binaryPath := filepath.Join(tmpDir, "educates-v1.0.0") - err = os.WriteFile(binaryPath, []byte("test binary"), 0755) - assert.NoError(t, err) - - symlinkPath := filepath.Join(tmpDir, "educates") - err = os.Symlink(binaryPath, symlinkPath) - assert.NoError(t, err) - - err = manager.ValidateDevelopmentMode() - assert.NoError(t, err) - - // Test with development symlink - err = os.Remove(symlinkPath) - assert.NoError(t, err) - - devBinaryPath := "/usr/local/bin/educates-dev" - err = os.Symlink(devBinaryPath, symlinkPath) - assert.NoError(t, err) - - err = manager.ValidateDevelopmentMode() - assert.Error(t, err) - assert.Contains(t, err.Error(), "development mode is disabled") -} - -func TestUseVersion(t *testing.T) { - manager, tmpDir, cleanup := setupTestManager(t) - defer cleanup() - - // Create test binary - version := "v1.0.0" - binaryPath := filepath.Join(tmpDir, "educates-"+version) - err := os.WriteFile(binaryPath, []byte("test binary"), 0755) - assert.NoError(t, err) - - // Test using version - err = manager.UseVersion(version) - assert.NoError(t, err) - - // Verify symlink - symlinkPath := filepath.Join(tmpDir, "educates") - target, err := os.Readlink(symlinkPath) - assert.NoError(t, err) - assert.Equal(t, "educates-"+version, filepath.Base(target)) - - // Test using development version when disabled - err = manager.UseVersion("develop") - assert.Error(t, err) - assert.Contains(t, err.Error(), "development mode is not enabled") -} - -// TODO: Implement proper mocking for GitHub client -// func TestInstallVersion(t *testing.T) { -// ... -// } diff --git a/pkg/version/uninstall.go b/pkg/version/uninstall.go new file mode 100644 index 0000000..cc1516a --- /dev/null +++ b/pkg/version/uninstall.go @@ -0,0 +1,78 @@ +package version + +import ( + "errors" + "fmt" + "io/fs" + "os" + "slices" +) + +// UninstallVersions removes the installed versions given, reporting each +// one it removes. It checks every version before it removes any, and +// reports every version it cannot uninstall, so that one bad argument +// leaves all of them in place. The active version is refused unless force +// is set; with force, the educates symlink goes too, and no version is +// active afterwards. +func (m *Manager) UninstallVersions(versions []string, force bool) error { + // A missing bin directory holds no versions. + installed, active, err := m.Installed() + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to read bin directory %s: %w", m.config.Local.Dir, err) + } + + var ( + toRemove []string + problems []error + activeVersion string + ) + seen := make(map[string]bool, len(versions)) + for _, version := range versions { + if seen[version] { + continue + } + seen[version] = true + + if version == Develop { + problems = append(problems, errors.New("cannot uninstall develop: educatesenv does not manage the development binary")) + continue + } + // Looking the version up among the installed ones keeps an + // argument that holds a path from reaching outside the bin + // directory. + if !slices.Contains(installed, version) { + problems = append(problems, fmt.Errorf("version %s is not installed. Run 'educatesenv list' to see installed versions", version)) + continue + } + if version == active { + if !force { + problems = append(problems, fmt.Errorf("version %s is active. Switch to another version with 'educatesenv use ' first, or pass --force to uninstall it", version)) + continue + } + activeVersion = version + } + toRemove = append(toRemove, version) + } + if len(problems) > 0 { + return errors.Join(problems...) + } + + for _, version := range toRemove { + if version == activeVersion { + // The symlink goes first, so that a failure leaves no symlink + // pointing at a removed binary. + if err := os.Remove(m.activeLink()); err != nil { + return fmt.Errorf("failed to remove the educates symlink: %w", err) + } + } + if err := os.Remove(m.binaryPath(version)); err != nil { + return fmt.Errorf("failed to uninstall version %s: %w", version, err) + } + _, _ = fmt.Fprintf(m.out, "educates %s uninstalled.\n", version) + } + + if activeVersion != "" { + _, _ = fmt.Fprintln(m.out, "No educates version is active now. Run 'educatesenv use ' to select one.") + } + return nil +} diff --git a/pkg/version/version.go b/pkg/version/version.go index 3727179..d775767 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -9,30 +9,30 @@ var ( // -ldflags="-X 'github.com/educates/educatesenv/pkg/version.Version=$TAG'" defaultVersion = "develop" Version = "" - moduleName = "github.com/educates/educatesenv" ) func init() { - Version = version() + info, ok := debug.ReadBuildInfo() + Version = buildVersion(Version, info, ok) } -func version() string { - if Version != "" { - // Version was set via ldflags, just return it. - return Version +// buildVersion returns the version a binary reports. A version set through +// ldflags wins. Otherwise it is the main module's version from the build +// info, which `go install module@version` records, and "develop" when the +// build info has none or records "(devel)". +func buildVersion(ldflagsVersion string, info *debug.BuildInfo, ok bool) string { + if ldflagsVersion != "" { + return ldflagsVersion } - info, ok := debug.ReadBuildInfo() - if !ok { + if !ok || info == nil { return defaultVersion } - // Anything else. - for _, dep := range info.Deps { - if dep.Path == moduleName { - return dep.Version - } + switch info.Main.Version { + case "", "(devel)": + return defaultVersion + default: + return info.Main.Version } - - return defaultVersion } diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..3fdec2f --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,36 @@ +package version + +import ( + "runtime/debug" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildVersion(t *testing.T) { + mainModule := func(version string) *debug.BuildInfo { + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/educates/educatesenv", Version: version}} + } + + tests := []struct { + name string + ldflags string + info *debug.BuildInfo + ok bool + want string + }{ + {name: "go install at a tag", info: mainModule("v1.1.0"), ok: true, want: "v1.1.0"}, + {name: "go install at a pseudo-version", info: mainModule("v1.0.1-0.20261026101500-0123456789ab"), ok: true, want: "v1.0.1-0.20261026101500-0123456789ab"}, + {name: "devel build", info: mainModule("(devel)"), ok: true, want: "develop"}, + {name: "main module without a version", info: mainModule(""), ok: true, want: "develop"}, + {name: "no build info", info: nil, ok: false, want: "develop"}, + {name: "ldflags win over build info", ldflags: "v1.1.0-rc.1", info: mainModule("v1.0.0"), ok: true, want: "v1.1.0-rc.1"}, + {name: "ldflags without build info", ldflags: "v1.1.0", info: nil, ok: false, want: "v1.1.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, buildVersion(tt.ldflags, tt.info, tt.ok)) + }) + } +}