├── .envrc ├── .github └── workflows │ ├── api-specs-pr.yml │ ├── go.yml │ ├── golangci-lint.yml │ └── releases.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── Taskfile.yml ├── api ├── crawler │ ├── client.go │ └── types.go ├── insights │ ├── client.go │ ├── responses.go │ └── types.go └── specs │ └── search.yml ├── cmd ├── algolia │ └── main.go └── docs │ └── main.go ├── devbox.json ├── devbox.lock ├── e2e ├── README.md ├── e2e_test.go └── testscripts │ ├── indices │ ├── indices.txtar │ └── replicas.txtar │ ├── objects │ └── objects.txtar │ ├── rules │ └── rules.txtar │ ├── search │ └── search.txtar │ ├── settings │ └── settings.txtar │ ├── synonyms │ └── synonyms.txtar │ └── version │ └── version.txtar ├── go.mod ├── go.sum ├── internal ├── analyze │ ├── analyze.go │ └── analyze_test.go ├── docs │ ├── docs.go │ ├── mdx.go │ ├── mdx.tpl │ └── yaml.go └── update │ ├── update.go │ └── update_test.go ├── pkg ├── ask │ └── ask.go ├── auth │ ├── auth_check.go │ └── auth_check_test.go ├── cmd │ ├── apikeys │ │ ├── apikeys.go │ │ ├── create │ │ │ ├── create.go │ │ │ └── create_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── get │ │ │ ├── get.go │ │ │ └── get_test.go │ │ └── list │ │ │ ├── list.go │ │ │ └── list_test.go │ ├── crawler │ │ ├── crawl │ │ │ ├── crawl.go │ │ │ └── crawl_test.go │ │ ├── crawler.go │ │ ├── create │ │ │ ├── create.go │ │ │ └── create_test.go │ │ ├── get │ │ │ └── get.go │ │ ├── list │ │ │ └── list.go │ │ ├── pause │ │ │ └── pause.go │ │ ├── reindex │ │ │ └── reindex.go │ │ ├── run │ │ │ └── run.go │ │ ├── stats │ │ │ └── stats.go │ │ ├── test │ │ │ └── test.go │ │ └── unblock │ │ │ └── unblock.go │ ├── dictionary │ │ ├── dictionary.go │ │ ├── entries │ │ │ ├── browse │ │ │ │ ├── browse.go │ │ │ │ └── browse_test.go │ │ │ ├── clear │ │ │ │ ├── clear.go │ │ │ │ └── clear_test.go │ │ │ ├── delete │ │ │ │ ├── delete.go │ │ │ │ └── delete_test.go │ │ │ ├── entries.go │ │ │ └── import │ │ │ │ ├── import.go │ │ │ │ └── import_test.go │ │ ├── settings │ │ │ ├── get │ │ │ │ └── get.go │ │ │ ├── set │ │ │ │ ├── languages.go │ │ │ │ ├── set.go │ │ │ │ └── set_test.go │ │ │ └── settings.go │ │ └── shared │ │ │ └── constants.go │ ├── events │ │ ├── events.go │ │ └── tail │ │ │ └── tail.go │ ├── factory │ │ └── default.go │ ├── indices │ │ ├── analyze │ │ │ └── analyze.go │ │ ├── clear │ │ │ ├── clear.go │ │ │ └── clear_test.go │ │ ├── config │ │ │ ├── config.go │ │ │ ├── export │ │ │ │ └── export.go │ │ │ └── import │ │ │ │ ├── confirm.go │ │ │ │ ├── confirm_test.go │ │ │ │ └── import.go │ │ ├── copy │ │ │ ├── copy.go │ │ │ └── copy_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── index.go │ │ ├── list │ │ │ └── list.go │ │ └── move │ │ │ ├── move.go │ │ │ └── move_test.go │ ├── objects │ │ ├── browse │ │ │ ├── browse.go │ │ │ └── browse_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── import │ │ │ ├── import.go │ │ │ └── import_test.go │ │ ├── objects.go │ │ ├── operations │ │ │ ├── operations.go │ │ │ └── operations_test.go │ │ └── update │ │ │ ├── update.go │ │ │ └── update_test.go │ ├── open │ │ └── open.go │ ├── profile │ │ ├── add │ │ │ ├── add.go │ │ │ └── add_test.go │ │ ├── application.go │ │ ├── list │ │ │ └── list.go │ │ ├── remove │ │ │ ├── remove.go │ │ │ └── remove_test.go │ │ └── setdefault │ │ │ ├── setdefault.go │ │ │ └── setdefault_test.go │ ├── root │ │ ├── help.go │ │ ├── root.go │ │ └── root_test.go │ ├── rules │ │ ├── browse │ │ │ ├── browse.go │ │ │ └── browse_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── import │ │ │ ├── import.go │ │ │ └── import_test.go │ │ └── rules.go │ ├── search │ │ └── search.go │ ├── settings │ │ ├── get │ │ │ └── list.go │ │ ├── import │ │ │ ├── import.go │ │ │ └── import_test.go │ │ ├── set │ │ │ ├── set.go │ │ │ └── set_test.go │ │ └── settings.go │ ├── shared │ │ ├── config │ │ │ └── config.go │ │ └── handler │ │ │ ├── flags_handler.go │ │ │ ├── indices │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ └── test_artifacts │ │ │ │ └── config_mock.json │ │ │ └── synonyms │ │ │ ├── synonyms.go │ │ │ └── synonyms_test.go │ └── synonyms │ │ ├── browse │ │ ├── browse.go │ │ └── browse_test.go │ │ ├── delete │ │ ├── delete.go │ │ └── delete_test.go │ │ ├── import │ │ ├── import.go │ │ └── import_test.go │ │ ├── save │ │ ├── messages.go │ │ ├── messages_test.go │ │ ├── save.go │ │ └── save_test.go │ │ ├── shared │ │ ├── flags_to_synonym.go │ │ └── flags_to_synonym_test.go │ │ └── synonyms.go ├── cmdutil │ ├── category_flagset.go │ ├── errors.go │ ├── factory.go │ ├── file_input.go │ ├── flags_completion.go │ ├── flags_completion_test.go │ ├── json_flags.go │ ├── json_value.go │ ├── json_value_test.go │ ├── jsonpath_flags.go │ ├── map_flags.go │ ├── map_flags_test.go │ ├── print_flags.go │ ├── spec_flags.go │ ├── telemetry_check.go │ ├── usage.go │ └── valid_args.go ├── config │ ├── config.go │ ├── profile.go │ └── validators.go ├── gen │ ├── flags.go.tpl │ └── gen_flags.go ├── httpmock │ ├── registry.go │ └── stub.go ├── iostreams │ ├── color.go │ ├── console.go │ ├── iostreams.go │ ├── iostreams_test.go │ ├── tty_size.go │ └── tty_size_windows.go ├── jsoncolor │ └── jsoncolor.go ├── open │ └── open.go ├── printers │ ├── interface.go │ ├── json.go │ ├── jsonpath.go │ ├── table_printer.go │ ├── table_printer_test.go │ └── template.go ├── prompt │ └── prompt.go ├── telemetry │ ├── telemetry.go │ └── telemetry_test.go ├── text │ ├── indent.go │ ├── indent_test.go │ ├── truncate.go │ └── truncate_test.go ├── utils │ ├── terminal.go │ ├── utils.go │ └── utils_test.go ├── validators │ ├── cmd.go │ └── validate.go └── version │ └── version.go ├── scripts └── completions.sh └── test ├── config.go └── helpers.go /.envrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Automatically sets up your devbox environment whenever you cd into this 4 | # directory via our direnv integration: 5 | 6 | eval "$(devbox generate direnv --print-envrc)" 7 | 8 | # check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ 9 | # for more details 10 | -------------------------------------------------------------------------------- /.github/workflows/api-specs-pr.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled API Specs Pull Request 2 | on: 3 | schedule: 4 | - cron: '0 */12 * * *' 5 | jobs: 6 | api-specs-pr: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: 1.23 14 | - run: make api-specs-pr 15 | env: 16 | GH_TOKEN: ${{ secrets.GH_SECRET }} 17 | GIT_COMMITTER_NAME: algolia-ci 18 | GIT_AUTHOR_NAME: algolia-ci 19 | GIT_COMMITTER_EMAIL: noreply@algolia.com 20 | GIT_AUTHOR_EMAIL: noreply@algolia.com 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Go 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: 1.23 16 | - name: Build 17 | run: go build -v ./... 18 | - name: Test 19 | run: go test -v ./... 20 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | permissions: 10 | contents: read 11 | pull-requests: read 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.23 20 | - uses: actions/checkout@v4 21 | - name: golangci-lint 22 | uses: golangci/golangci-lint-action@v6 23 | with: 24 | version: v1.63.4 25 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write # publishing releases 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Code checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.23 20 | - name: Install chocolatey 21 | run: | 22 | mkdir -p /opt/chocolatey 23 | wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" 24 | echo '#!/bin/bash' >> /usr/local/bin/choco 25 | echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco 26 | chmod +x /usr/local/bin/choco 27 | env: 28 | CHOCOLATEY_VERSION: 1.2.0 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | version: "~> v2" 33 | args: release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 36 | CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} 37 | - name: Docs checkout 38 | uses: actions/checkout@v4 39 | with: 40 | repository: algolia/doc 41 | path: docs 42 | fetch-depth: 0 43 | token: ${{secrets.GORELEASER_GITHUB_TOKEN}} 44 | - name: Update docs 45 | env: 46 | GIT_COMMITTER_NAME: algolia-ci 47 | GIT_AUTHOR_NAME: algolia-ci 48 | GIT_COMMITTER_EMAIL: noreply@algolia.com 49 | GIT_AUTHOR_EMAIL: noreply@algolia.com 50 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 51 | run: | 52 | make docs-pr 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /dist 3 | 4 | # VS Code 5 | .vscode 6 | 7 | # IntelliJ 8 | .idea 9 | 10 | # macOS 11 | .DS_Store 12 | 13 | # vim 14 | *.swp 15 | 16 | vendor/ 17 | 18 | # local build 19 | algolia 20 | 21 | # Environment variables 22 | *.env -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gosec 4 | - gofumpt 5 | - stylecheck 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the binary 2 | FROM golang:1.23-alpine AS builder 3 | WORKDIR /app 4 | COPY . . 5 | ARG VERSION=docker 6 | RUN apk update && apk add --no-cache curl 7 | RUN go mod download 8 | RUN go install github.com/go-task/task/v3/cmd/task@latest 9 | RUN task download-spec-file && VERSION=${VERSION} task build 10 | 11 | FROM alpine:3 12 | RUN apk update && apk upgrade && apk add --no-cache ca-certificates 13 | COPY --from=builder /app/algolia /bin/algolia 14 | ENTRYPOINT ["/bin/algolia"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Algolia 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifdef VERSION 2 | VERSION := $(VERSION) 3 | else 4 | VERSION := main 5 | endif 6 | 7 | # Run all the tests 8 | test: 9 | go test ./... -p 1 10 | .PHONY: test 11 | 12 | ## Build & publish the old documentation 13 | VARIATION ?= old 14 | ifeq ($(VARIATION),old) 15 | DOCS_FOLDER = docs 16 | DOCS_GENERATED_PATH = app_data/cli/commands 17 | DOCS_REPO_URL = git@github.com:algolia/doc.git 18 | DOCS_BRANCH = master 19 | DOCS_EXTENSION = yml 20 | else ifeq ($(VARIATION),new) 21 | DOCS_FOLDER = new-world-docs 22 | DOCS_GENERATED_PATH = apps/docs/content/pages/tools/cli/commands 23 | DOCS_REPO_URL = https://github.com/algolia/new-world-docs.git 24 | DOCS_BRANCH = main 25 | DOCS_EXTENSION = mdx 26 | endif 27 | 28 | docs: 29 | git clone $(DOCS_REPO_URL) "$@" 30 | 31 | .PHONY: docs-commands-data 32 | docs-commands-data: docs 33 | git -C $(DOCS_FOLDER) pull 34 | git -C $(DOCS_FOLDER) checkout $(DOCS_BRANCH) 35 | git -C $(DOCS_FOLDER) rm '$(DOCS_GENERATED_PATH)/*.$(DOCS_EXTENSION)' 2>/dev/null || true 36 | go run ./cmd/docs --app_data-path $(DOCS_FOLDER)/$(DOCS_GENERATED_PATH) --target $(VARIATION) 37 | git -C $(DOCS_FOLDER) add '$(DOCS_GENERATED_PATH)/*.$(DOCS_EXTENSION)' 38 | 39 | .PHONY: docs-pr 40 | docs-pr: docs-commands-data 41 | ifndef GITHUB_REF 42 | $(error GITHUB_REF is not set) 43 | endif 44 | git -C $(DOCS_FOLDER) checkout -B feat/cli-'$(GITHUB_REF:refs/tags/v%=%)' 45 | git -C $(DOCS_FOLDER) commit -m 'feat: update cli commands data for $(GITHUB_REF:refs/tags/v%=%) version' || true 46 | git -C $(DOCS_FOLDER) push --set-upstream origin feat/cli-'$(GITHUB_REF:refs/tags/v%=%)' 47 | cd $(DOCS_FOLDER); gh pr create -f -b "Changelog: https://github.com/algolia/cli/releases/tag/$(GITHUB_REF:refs/tags/%=%)" 48 | 49 | ## Create a new PR (or update the existing one) to update the API specs 50 | api-specs-pr: 51 | wget -O ./api/specs/search.yml https://raw.githubusercontent.com/algolia/api-clients-automation/main/specs/bundled/search.yml 52 | go generate ./... 53 | if [ -n "$$(git status --porcelain)" ]; then \ 54 | git checkout -b feat/api-specs; \ 55 | git add .; \ 56 | git commit -m 'chore: update search api specs'; \ 57 | git push -f --set-upstream origin feat/api-specs; \ 58 | if ! [ "$$(gh pr list --base main --head feat/api-specs)" ]; then gh pr create --title "Update search api specs" --body "Update search api specs"; fi; \ 59 | fi 60 | 61 | # Build the binary 62 | build: 63 | go generate ./... 64 | go build -ldflags "-s -w -X=github.com/algolia/cli/pkg/version.Version=$(VERSION)" -o algolia cmd/algolia/main.go 65 | .PHONY: build 66 | 67 | ## Install & uninstall tasks are here for use on *nix platform only. 68 | prefix := /usr/local 69 | bindir := ${prefix}/bin 70 | 71 | # Install Algolia CLI 72 | install: 73 | make build 74 | install -m755 algolia ${bindir} 75 | .PHONY: install 76 | 77 | # Uninstall Algolia CLI 78 | uninstall: 79 | rm ${bindir}/algolia 80 | .PHONY: uninstall 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Algolia CLI 2 | 3 | The Algolia CLI lets you work with your Algolia resources, 4 | such as indices, records, API keys, and synonyms, 5 | and from the command line. 6 | 7 | ![cli](https://user-images.githubusercontent.com/5702266/153008646-1fd8fbf2-4a4d-4421-b2f2-0886487f3e27.png) 8 | 9 | ## Documentation 10 | 11 | See [Algolia CLI](https://algolia.com/doc/tools/cli/) in the Algolia documentation for setup and usage instructions. 12 | 13 | ## Installation 14 | 15 | ### macOS 16 | 17 | The Algolia CLI is available on [Homebrew](https://brew.sh/) and as a downloadable binary from the [releases page](https://github.com/algolia/cli/releases). 18 | 19 | ```sh 20 | brew install algolia/algolia-cli/algolia 21 | ``` 22 | 23 | ### Linux 24 | 25 | The Algolia CLI is available as a `.deb` package: 26 | 27 | ```sh 28 | # Select the package appropriate for your platform: 29 | sudo dpkg -i algolia_*.deb 30 | ``` 31 | 32 | as a `.rpm` package: 33 | 34 | ```sh 35 | # Select the package appropriate for your platform 36 | sudo rpm -i algolia_*.rpm 37 | ``` 38 | 39 | or as a tarball from the [releases page](https://github.com/algolia/cli/releases): 40 | 41 | ```sh 42 | # Select the archive appropriate for your platform 43 | tar xvf algolia_*_linux_*.tar.gz 44 | ``` 45 | 46 | ### Windows 47 | 48 | The Algolia CLI is available via [Chocolatey](https://community.chocolatey.org/packages/algolia/) and as a downloadable binary from the [releases page](https://github.com/algolia/cli/releases) 49 | 50 | ### Community packages 51 | 52 | Other packages are maintained by the community, not by Algolia. 53 | If you distribute a package for the Algolia CLI, create a pull request so that we can list it here! 54 | 55 | ### Build from source 56 | 57 | To build the Algolia CLI from source, you'll need: 58 | 59 | - Go version 1.23 or later 60 | - [Go task](https://taskfile.dev/) 61 | 62 | 1. Clone the repo: `git clone https://github.com/kai687/cli.git algolia-cli && cd algolia-cli` 63 | 1. Run: `task build` 64 | 65 | ## Support 66 | 67 | If you found an issue with the Algolia CLI, 68 | [open a new GitHub issue](https://github.com/algolia/cli/issues/new), 69 | or join the Algolia community on [Discord](https://alg.li/discord). 70 | -------------------------------------------------------------------------------- /api/insights/client.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | algoliaInsights "github.com/algolia/algoliasearch-client-go/v4/algolia/insights" 9 | "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" 10 | "github.com/algolia/cli/pkg/version" 11 | ) 12 | 13 | // Client wraps the default Insights API client so that we can declare methods on it 14 | type Client struct { 15 | *algoliaInsights.APIClient 16 | } 17 | 18 | // NewClient instantiates a new Insights API client 19 | func NewClient(appID, apiKey string, region algoliaInsights.Region) (*Client, error) { 20 | // Get the default user agent 21 | userAgent, err := getUserAgentInfo(appID, apiKey, region, version.Version) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if userAgent == "" { 26 | return nil, fmt.Errorf("user agent info must not be empty") 27 | } 28 | clientConfig := algoliaInsights.InsightsConfiguration{ 29 | Configuration: transport.Configuration{ 30 | AppID: appID, 31 | ApiKey: apiKey, 32 | UserAgent: userAgent, 33 | }, 34 | } 35 | client, err := algoliaInsights.NewClientWithConfig(clientConfig) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return &Client{client}, nil 40 | } 41 | 42 | // GetEvents retrieves a number of events from the Algolia Insights API. 43 | func (c *Client) GetEvents(startDate, endDate time.Time, limit int) (*EventsRes, error) { 44 | layout := "2006-01-02T15:04:05.000Z" 45 | params := map[string]any{ 46 | "startDate": startDate.Format(layout), 47 | "endDate": endDate.Format(layout), 48 | "limit": limit, 49 | } 50 | res, err := c.CustomGet(c.NewApiCustomGetRequest("1/events").WithParameters(params)) 51 | if err != nil { 52 | return nil, err 53 | } 54 | tmp, err := json.Marshal(res) 55 | if err != nil { 56 | return nil, err 57 | } 58 | var eventsRes EventsRes 59 | err = json.Unmarshal(tmp, &eventsRes) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return &eventsRes, err 65 | } 66 | 67 | // getUserAgentInfo returns the user agent string for the Insights client in the CLI 68 | func getUserAgentInfo( 69 | appID string, 70 | apiKey string, 71 | region algoliaInsights.Region, 72 | appVersion string, 73 | ) (string, error) { 74 | client, err := algoliaInsights.NewClient(appID, apiKey, region) 75 | if err != nil { 76 | return "", err 77 | } 78 | 79 | return client.GetConfiguration().UserAgent + fmt.Sprintf("; Algolia CLI (%s)", appVersion), nil 80 | } 81 | -------------------------------------------------------------------------------- /api/insights/responses.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | type EventsRes struct { 4 | Events []EventWrapper `json:"events"` 5 | } 6 | -------------------------------------------------------------------------------- /api/insights/types.go: -------------------------------------------------------------------------------- 1 | package insights 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type EventWrapper struct { 9 | Event Event `json:"event"` 10 | RequestID string `json:"requestID"` 11 | Status int `json:"status"` 12 | Errors []string `json:"errors"` 13 | Headers map[string][]string 14 | } 15 | 16 | type Timestamp struct { 17 | time.Time 18 | } 19 | 20 | func (t *Timestamp) UnmarshalJSON(data []byte) error { 21 | var timestamp int64 22 | if err := json.Unmarshal(data, ×tamp); err != nil { 23 | return err 24 | } 25 | *t = Timestamp{time.Unix(0, timestamp*int64(time.Millisecond))} 26 | return nil 27 | } 28 | 29 | // TODO: Replace this with a type from the API. 30 | type Event struct { 31 | EventType string `json:"eventType"` 32 | EventName string `json:"eventName"` 33 | Index string `json:"index"` 34 | UserToken string `json:"userToken"` 35 | Timestamp Timestamp `json:"timestamp"` 36 | ObjectIDs []string `json:"objectIDs,omitempty"` 37 | Positions []int `json:"positions,omitempty"` 38 | QueryID string `json:"queryID,omitempty"` 39 | Filters []string `json:"filters,omitempty"` 40 | } 41 | -------------------------------------------------------------------------------- /cmd/algolia/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/algolia/cli/pkg/cmd/root" 7 | ) 8 | 9 | func main() { 10 | code := root.Execute() 11 | os.Exit(int(code)) 12 | } 13 | -------------------------------------------------------------------------------- /cmd/docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/pflag" 9 | 10 | "github.com/algolia/cli/internal/docs" 11 | "github.com/algolia/cli/pkg/cmd/root" 12 | "github.com/algolia/cli/pkg/cmdutil" 13 | "github.com/algolia/cli/pkg/config" 14 | "github.com/algolia/cli/pkg/iostreams" 15 | ) 16 | 17 | func main() { 18 | if err := run(os.Args); err != nil { 19 | fmt.Fprintln(os.Stderr, err) 20 | os.Exit(1) 21 | } 22 | } 23 | 24 | func run(args []string) error { 25 | flags := pflag.NewFlagSet("", pflag.ContinueOnError) 26 | dir := flags.StringP( 27 | "app_data-path", 28 | "", 29 | "", 30 | "Path directory where you want generate documentation data files", 31 | ) 32 | help := flags.BoolP("help", "h", false, "Help about any command") 33 | target := flags.StringP("target", "T", "old", "target old or new documentation website") 34 | 35 | if *target != "old" && *target != "new" { 36 | return fmt.Errorf("error: --destination can only be 'old' or 'new' ('old' by default)") 37 | } 38 | 39 | if err := flags.Parse(args); err != nil { 40 | return err 41 | } 42 | 43 | if *help { 44 | fmt.Fprintf(os.Stderr, "Usage of %s:\n\n%s", filepath.Base(args[0]), flags.FlagUsages()) 45 | return nil 46 | } 47 | 48 | if *dir == "" { 49 | return fmt.Errorf("error: --app_data-path not set") 50 | } 51 | 52 | ios, _, _, _ := iostreams.Test() 53 | rootCmd := root.NewRootCmd(&cmdutil.Factory{ 54 | IOStreams: ios, 55 | Config: &config.Config{}, 56 | }) 57 | rootCmd.InitDefaultHelpCmd() 58 | 59 | if err := os.MkdirAll(*dir, 0o755); err != nil { 60 | return err 61 | } 62 | 63 | if *target == "old" { 64 | if err := docs.GenYamlTree(rootCmd, *dir); err != nil { 65 | return err 66 | } 67 | } else { 68 | if err := docs.GenMdxTree(rootCmd, *dir); err != nil { 69 | return err 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json", 3 | "packages": [ 4 | "go-task@latest", 5 | "go@1.23.4", 6 | "golangci-lint@1.63.4", 7 | "gofumpt@latest", 8 | "golines@latest", 9 | "gh@latest", 10 | "curl@latest" 11 | ], 12 | "env_from": ".env" 13 | } 14 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # End-to-end tests 2 | 3 | These tests run CLI commands like a user would, 4 | built on top of the [`go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) package. 5 | 6 | They make real API requests, 7 | so they work best in an empty Algolia application. 8 | To run these tests, 9 | you need to set the `ALGOLIA_APPLICATION_ID` and `ALGOLIA_API_KEY` environment variables. 10 | If you're using `devbox`, create a `.env` file in the project root directory with these variables. 11 | If you start a development environment with `devbox shell`, 12 | the environment variables will be available to you. 13 | 14 | ## New tests 15 | 16 | The tests use a simple format. 17 | For more information, run `go doc testscript`. 18 | 19 | To add a new scenario, create a new directory under the `testscripts` directory, 20 | and add your files with the extension `txtar`. 21 | Each test directory can have multiple test files. 22 | Multiple directories are tested in parallel. 23 | 24 | ### Example 25 | 26 | A simple 'hello world' testscript may look like this: 27 | 28 | ```txt 29 | # Test if output is hello 30 | exec echo 'hello' 31 | ! stderr . 32 | stdout '^hello\n$' 33 | ``` 34 | 35 | Read the documentation of the `testscript` package for more information. 36 | 37 | To add the new directory to the test suite, 38 | add a new function to the file `./e2e/e2e_test.go`. 39 | The function name must begin with `Test`. 40 | 41 | ```go 42 | // TestHello is a basic example 43 | func TestHello(t *testing.T) { 44 | RunTestsInDir(t, "testscripts/hello") 45 | } 46 | ``` 47 | 48 | ## Notes 49 | 50 | Since this makes real real requests to the same Algolia application, 51 | these tests aren't fully isolated from each other. 52 | 53 | To make tests interfere less, follow these guidelines: 54 | 55 | - Use a unique index name in each `txtar` file. 56 | For example, use `test-index` in `indices.txtar` and `test-settings` in `settings.txtar` 57 | 58 | - Delete indices at the end of your test with `defer`. 59 | For an example, see `indices.txtar`. 60 | 61 | - Don't test for number of indices, or empty lists. 62 | As other tests might create their own indices and objects, 63 | checks that expect a certain number of items might fail. 64 | You can ensure that the index with a given name exists or doesn't exist 65 | by searching for the index name's pattern in the standard output. 66 | Again, see `indices.txtar`. 67 | -------------------------------------------------------------------------------- /e2e/testscripts/indices/indices.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-index 2 | env COPY_NAME=test-copy 3 | 4 | # Create a new index 5 | exec algolia settings set ${INDEX_NAME} --searchableAttributes "foo" --wait 6 | ! stderr . 7 | 8 | # Cleanup 9 | defer algolia indices delete ${INDEX_NAME} --confirm 10 | 11 | # Confirm that the index setting is set 12 | exec algolia settings get ${INDEX_NAME} 13 | stdout -count=1 '"searchableAttributes":\["foo"\]' 14 | 15 | # Test that index is listed 16 | exec algolia indices list 17 | stdout -count=1 ^${INDEX_NAME} 18 | 19 | # Copy the index 20 | exec algolia indices copy ${INDEX_NAME} ${COPY_NAME} --wait --confirm 21 | ! stderr . 22 | 23 | # Confirm that there are 2 indices now 24 | exec algolia indices list 25 | stdout -count=1 ^${INDEX_NAME} 26 | stdout -count=1 ^${COPY_NAME} 27 | 28 | # Add replica indices to the copy 29 | exec algolia settings set ${COPY_NAME} --replicas 'test-replica1,test-replica2' --wait 30 | ! stderr . 31 | 32 | # Confirm that there are 4 indices now 33 | exec algolia indices list 34 | stdout -count=1 ^${INDEX_NAME} 35 | stdout -count=1 ^${COPY_NAME} 36 | stdout -count=1 ^test-replica1 37 | stdout -count=1 ^test-replica2 38 | 39 | # Delete one of the replica indices 40 | exec algolia indices delete test-replica1 --confirm --wait 41 | ! stderr . 42 | 43 | # Confirm that there are 3 indices now 44 | exec algolia indices list 45 | stdout -count=1 ^${INDEX_NAME} 46 | stdout -count=1 ^${COPY_NAME} 47 | ! stdout ^test-replica1 48 | stdout -count=1 ^test-replica2 49 | 50 | # Confirm that the test-copy index still has 1 replica index 51 | exec algolia settings get ${COPY_NAME} 52 | stdout -count=1 test-replica2 53 | ! stdout test-replica1 54 | 55 | # Delete the copy index including its replicas 56 | exec algolia indices delete ${COPY_NAME} --include-replicas --confirm --wait 57 | ! stderr . 58 | 59 | # Confirm that there is 1 index now 60 | exec algolia indices list 61 | stdout -count=1 ^${INDEX_NAME} 62 | ! stdout ^${COPY_NAME} 63 | ! stdout ^test-replica1 64 | ! stdout ^test-replica2 65 | -------------------------------------------------------------------------------- /e2e/testscripts/indices/replicas.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-can-delete-index 2 | env REPLICA_NAME=test-can-delete-replica 3 | 4 | # Create a new index with one replica index 5 | exec algolia settings set ${INDEX_NAME} --replicas ${REPLICA_NAME} --wait 6 | ! stderr . 7 | 8 | # Check that you can delete both manually 9 | exec algolia index delete ${INDEX_NAME} ${REPLICA_NAME} --confirm 10 | ! stderr . 11 | ! stdout . 12 | 13 | # Check that both indices have been deleted 14 | exec algolia index list 15 | ! stderr . 16 | ! stdout ${INDEX_NAME} 17 | ! stdout ${REPLICA_NAME} 18 | -------------------------------------------------------------------------------- /e2e/testscripts/objects/objects.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-objects 2 | 3 | # Import a record without objectID from a file 4 | ! exec algolia objects import ${INDEX_NAME} --file record.jsonl --wait 5 | ! stdout . 6 | stderr '^missing objectID on line 0$' 7 | 8 | # Defer cleanup 9 | defer algolia index delete ${INDEX_NAME} --confirm 10 | 11 | # Import a record with autogenerated objectID 12 | exec algolia objects import ${INDEX_NAME} --file record.jsonl --wait --auto-generate-object-id-if-not-exist 13 | ! stderr . 14 | 15 | # Check that record exists (use aliases) 16 | exec algolia records list ${INDEX_NAME} 17 | ! stderr . 18 | stdout -count=1 '"name":"foo"' 19 | stdout -count=1 'objectID' 20 | 21 | # Add another record from stdin with objectID 22 | stdin objectID.jsonl 23 | exec algolia records import ${INDEX_NAME} --wait --file - 24 | ! stderr . 25 | 26 | # Update a record 27 | exec algolia objects update ${INDEX_NAME} --file update.jsonl --wait --create-if-not-exists --continue-on-error 28 | ! stderr . 29 | 30 | # Check that record has that new attribute 31 | exec algolia objects browse ${INDEX_NAME} 32 | ! stderr . 33 | stdout -count=1 '"level":1' 34 | 35 | -- record.jsonl -- 36 | {"name": "foo"} 37 | 38 | -- objectID.jsonl -- 39 | {"objectID": "test-record-1", "name": "test"} 40 | 41 | -- update.jsonl -- 42 | {"objectID": "test-record-1", "level": 1} 43 | -------------------------------------------------------------------------------- /e2e/testscripts/rules/rules.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-rules 2 | 3 | # List rules (empty index should return error) 4 | ! exec algolia rules browse ${INDEX_NAME} 5 | ! stdout . 6 | stderr -count=1 'index test-rules doesn''t exist' 7 | 8 | # Importing a rule without objectID should fail 9 | stdin without-objectID.json 10 | ! exec algolia rules import ${INDEX_NAME} --file - 11 | ! stdout . 12 | stderr objectID 13 | 14 | # Importing a rule without consequence should also fail 15 | stdin without-consequence.json 16 | ! exec algolia rules import ${INDEX_NAME} --file - 17 | ! stdout . 18 | stderr consequence 19 | 20 | # Import rule 21 | exec algolia rules import ${INDEX_NAME} --file rules.json --wait 22 | ! stderr . 23 | ! stdout . 24 | 25 | # Delete the rule 26 | exec algolia rules delete ${INDEX_NAME} --rule-ids "test-rule-1" --wait --confirm 27 | ! stderr . 28 | ! stdout . 29 | 30 | # Defer cleanup 31 | defer algolia index delete ${INDEX_NAME} --confirm 32 | ! stderr . 33 | 34 | -- without-objectID.json -- 35 | {} 36 | 37 | -- without-consequence.json -- 38 | {"objectID": "foo"} 39 | 40 | -- rules.json -- 41 | {"conditions":[{"anchoring":"contains","pattern":"foo"}],"consequence":{"promote":[{"objectID":"foo","position":0}]},"objectID":"test-rule-1"} 42 | -------------------------------------------------------------------------------- /e2e/testscripts/search/search.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-search 2 | 3 | # Add a record to an index 4 | exec algolia records import ${INDEX_NAME} --file record.json --wait 5 | ! stderr . 6 | ! stdout . 7 | 8 | # Defer cleanup 9 | defer algolia index delete ${INDEX_NAME} --confirm 10 | ! stderr . 11 | 12 | # Search for something 13 | exec algolia search ${INDEX_NAME} --query "test" 14 | ! stderr . 15 | stdout -count=1 '"nbHits":1' 16 | 17 | -- record.json -- 18 | {"objectID": "test-record-1", "name": "Test record"} 19 | -------------------------------------------------------------------------------- /e2e/testscripts/settings/settings.txtar: -------------------------------------------------------------------------------- 1 | # Test importing settings from a file 2 | exec algolia settings import test-settings --file settings.json --wait 3 | ! stderr . 4 | ! stdout . 5 | 6 | # Defer deleting the test index 7 | defer algolia indices delete test-settings --confirm --include-replicas 8 | 9 | # Check that settings are applied 10 | exec algolia settings get test-settings 11 | stdout -count=1 '"searchableAttributes":\["foo"\]' 12 | 13 | # Test applying some settings from flags 14 | exec algolia settings set test-settings --attributesToRetrieve "foo" --searchableAttributes "bar" --unretrievableAttributes "baz" --attributesForFaceting "searchable(bar)" --replicas "test-settings-replica" --wait 15 | ! stderr . 16 | 17 | # Test that the correct settings are applied 18 | exec algolia settings get test-settings 19 | stdout -count=1 '"attributesToRetrieve":\["foo"\]' 20 | stdout -count=1 '"searchableAttributes":\["bar"\]' 21 | stdout -count=1 '"unretrievableAttributes":\["baz"\]' 22 | stdout -count=1 '"attributesForFaceting":\["searchable\(bar\)"\]' 23 | stdout -count=1 '"replicas":\["test-settings-replica"\]' 24 | 25 | # Change a setting 26 | exec algolia settings set test-settings --searchableAttributes "not-changed" --wait 27 | ! stderr . 28 | 29 | # Check that change is not applied to replica 30 | exec algolia settings get test-settings-replica 31 | ! stdout not-changed 32 | 33 | # Change another setting and forward change to replica 34 | exec algolia settings set test-settings --searchableAttributes "changed" --forward-to-replicas --wait 35 | ! stderr . 36 | 37 | # Check that change is also applied to replica 38 | exec algolia settings get test-settings-replica 39 | stdout -count=1 changed 40 | 41 | -- settings.json -- 42 | {"searchableAttributes": ["foo"]} 43 | -------------------------------------------------------------------------------- /e2e/testscripts/synonyms/synonyms.txtar: -------------------------------------------------------------------------------- 1 | env INDEX_NAME=test-synonyms 2 | 3 | # List synonyms (empty index should return error) 4 | ! exec algolia synonyms browse ${INDEX_NAME} 5 | ! stdout . 6 | stderr -count=1 'index test-synonyms doesn''t exist' 7 | 8 | # Import synonyms from a file 9 | exec algolia synonyms import ${INDEX_NAME} --file synonyms.jsonl --wait 10 | ! stderr . 11 | ! stdout . 12 | 13 | # Defer cleanup 14 | defer algolia index delete ${INDEX_NAME} --confirm 15 | ! stderr . 16 | 17 | # Import a synonym from the command line 18 | stdin stdin.json 19 | exec algolia synonyms import ${INDEX_NAME} --file - --wait 20 | ! stderr . 21 | ! stdout . 22 | 23 | # Save a synonym using flags 24 | exec algolia synonyms save ${INDEX_NAME} --id 'test-synonym-4' --type altCorrection1 --word foo --corrections bar --wait 25 | ! stderr . 26 | ! stdout . 27 | 28 | # List synonyms 29 | exec algolia synonyms browse ${INDEX_NAME} 30 | ! stderr . 31 | stdout -count=4 'objectID' 32 | 33 | -- synonyms.jsonl -- 34 | {"objectID": "test-synonym-1", "type": "synonym", "synonyms": ["foo", "bar"]} 35 | {"objectID": "test-synonym-2", "type": "synonym", "synonyms": ["bar", "baz"]} 36 | 37 | -- stdin.json -- 38 | {"objectID": "test-synonym-3", "type": "onewaysynonym", "input": "add", "synonyms": ["save"]} 39 | -------------------------------------------------------------------------------- /e2e/testscripts/version/version.txtar: -------------------------------------------------------------------------------- 1 | # Check that we're using the correct version 2 | exec algolia --version 3 | stdout '^algolia version main$' 4 | -------------------------------------------------------------------------------- /internal/docs/mdx.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func GenMdxTree(cmd *cobra.Command, dir string) error { 14 | tpl, err := template.New("mdx.tpl").Funcs(template.FuncMap{ 15 | "getExamples": func(cmd Command) []Example { 16 | return cmd.ExamplesList() 17 | }, 18 | }).ParseFiles("internal/docs/mdx.tpl") 19 | if err != nil { 20 | return err 21 | } 22 | 23 | commands := getCommands(cmd) 24 | 25 | for _, c := range commands { 26 | c.Slug = strings.ReplaceAll(c.Name, " ", "-") 27 | filename := filepath.Join(dir, fmt.Sprintf("%s.mdx", c.Slug)) 28 | file, err := os.Create(filename) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | err = tpl.Execute(file, c) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/docs/mdx.tpl: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: "cli" 3 | title: |- 4 | {{ .Name }} 5 | description: |- 6 | {{ .Description }} 7 | slug: tools/cli/commands/{{ .Slug }} 8 | --- 9 | {{ if .SubCommands }}{{ range $subCommand := .SubCommands }}{{ if $subCommand.SubCommands }}{{ range $susCommand := $subCommand.SubCommands}} 10 | ## {{ $susCommand.Name }} 11 | 12 | {{ $susCommand.Description }} 13 | 14 | ### Usage 15 | 16 | `{{ $susCommand.Usage }}` 17 | 18 | {{ $examples := getExamples $susCommand }}{{ if $examples }} 19 | ### Examples 20 | {{ range $example := $examples }}{{ $example.Desc }} 21 | 22 | ```sh {{ if $example.WebCLICommand }}command="{{$example.WebCLICommand}}"{{ end }} 23 | {{ $example.Code }} 24 | ``` 25 | {{ end }}{{ end }}{{ range $flagKey, $flagSlice := $susCommand.Flags }}{{ if $flagSlice }} 26 | ### Flags 27 | {{ range $flag := $flagSlice }} 28 | - {{ if $flag.Shorthand }}`-{{ $flag.Shorthand }}`, {{ end }}`--{{ $flag.Name }}`: {{ $flag.Description }} 29 | {{ end }}{{ end }}{{ end }}{{ end }} 30 | {{ else }} 31 | ## {{ $subCommand.Name }} 32 | 33 | {{ $subCommand.Description }} 34 | 35 | ### Usage 36 | 37 | `{{ $subCommand.Usage }}` 38 | 39 | {{ $examples := getExamples $subCommand }} 40 | {{ if $examples }} 41 | ### Examples 42 | {{ range $example := $examples }} 43 | {{ $example.Desc }} 44 | 45 | ```sh {{ if $example.WebCLICommand }}command="{{$example.WebCLICommand}}"{{ end }} 46 | {{ $example.Code }} 47 | ``` 48 | {{ end }} 49 | {{ end }} 50 | 51 | {{ range $flagKey, $flagSlice := $subCommand.Flags }} 52 | {{ if $flagSlice }} 53 | ### Flags 54 | {{ range $flag := $flagSlice }} 55 | - {{ if $flag.Shorthand }}`-{{ $flag.Shorthand }}`, {{ end }}`--{{ $flag.Name }}`: {{ $flag.Description }} 56 | {{ end }}{{ end }}{{ end }}{{ end}}{{ end }} 57 | {{ else }}## {{ .Name }} 58 | 59 | ### Usage 60 | 61 | `{{ .Usage }}` 62 | {{ $examples := getExamples . }}{{ if $examples }} 63 | ### Examples 64 | {{ range $example := $examples }} 65 | {{ $example.Desc }} 66 | 67 | ```sh {{ if $example.WebCLICommand }}command="{{$example.WebCLICommand}}"{{ end }} 68 | {{ $example.Code }} 69 | ``` 70 | {{ end }}{{ end }} 71 | {{ range $flagKey, $flagSlice := .Flags }} 72 | ### {{ $flagKey }} 73 | {{ range $flag := $flagSlice }} 74 | - {{ if $flag.Shorthand }}`-{{ $flag.Shorthand }}`, {{ end }}`--{{ $flag.Name }}`: {{ $flag.Description }} 75 | {{ end }}{{ end }}{{ end }} -------------------------------------------------------------------------------- /internal/docs/yaml.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func GenYamlTree(cmd *cobra.Command, dir string) error { 14 | commands := getCommands(cmd) 15 | 16 | for _, c := range commands { 17 | commandPath := strings.ReplaceAll(c.Name, " ", "_") 18 | filename := filepath.Join(dir, fmt.Sprintf("%s.yml", commandPath)) 19 | f, err := os.Create(filename) 20 | if err != nil { 21 | return err 22 | } 23 | defer f.Close() 24 | 25 | // Write a comment to the file, ignoring vale errors 26 | _, err = f.WriteString("# \n") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | encoder := yaml.NewEncoder(f) 32 | defer encoder.Close() 33 | 34 | err = encoder.Encode(c) 35 | if err != nil { 36 | return err 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/update/update.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/cli/cli/blob/trunk/internal/update/update.go 2 | package update 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/hashicorp/go-version" 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | const repo = "algolia/cli" 20 | 21 | var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`) 22 | 23 | // ReleaseInfo stores information about a release 24 | type ReleaseInfo struct { 25 | Version string `json:"tag_name"` 26 | URL string `json:"html_url"` 27 | PublishedAt time.Time `json:"published_at"` 28 | } 29 | 30 | type StateEntry struct { 31 | CheckedForUpdateAt time.Time `yaml:"checked_for_update_at"` 32 | LatestRelease ReleaseInfo `yaml:"latest_release"` 33 | } 34 | 35 | // CheckForUpdate checks whether this software has had a newer release on GitHub 36 | func CheckForUpdate( 37 | client *http.Client, 38 | stateFilePath, currentVersion string, 39 | ) (*ReleaseInfo, error) { 40 | stateEntry, _ := getStateEntry(stateFilePath) 41 | if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { 42 | return nil, nil 43 | } 44 | 45 | releaseInfo, err := getLatestReleaseInfo(client) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | err = setStateEntry(stateFilePath, time.Now(), *releaseInfo) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if versionGreaterThan(releaseInfo.Version, currentVersion) { 56 | return releaseInfo, nil 57 | } 58 | 59 | return nil, nil 60 | } 61 | 62 | func getLatestReleaseInfo(client *http.Client) (*ReleaseInfo, error) { 63 | var latestRelease ReleaseInfo 64 | resp, err := client.Get(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if resp.StatusCode != 200 { 70 | return nil, fmt.Errorf("unexpected response code: %d", resp.StatusCode) 71 | } 72 | 73 | err = json.NewDecoder(resp.Body).Decode(&latestRelease) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return &latestRelease, nil 79 | } 80 | 81 | func getStateEntry(stateFilePath string) (*StateEntry, error) { 82 | content, err := os.ReadFile(stateFilePath) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | var stateEntry StateEntry 88 | err = yaml.Unmarshal(content, &stateEntry) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &stateEntry, nil 94 | } 95 | 96 | func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error { 97 | data := StateEntry{CheckedForUpdateAt: t, LatestRelease: r} 98 | content, err := yaml.Marshal(data) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | err = os.MkdirAll(filepath.Dir(stateFilePath), 0o755) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | err = os.WriteFile(stateFilePath, content, 0o600) 109 | return err 110 | } 111 | 112 | func versionGreaterThan(v, w string) bool { 113 | w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string { 114 | idx := strings.IndexRune(m, '-') 115 | n, _ := strconv.Atoi(m[0:idx]) 116 | return fmt.Sprintf("%d-pre.0", n+1) 117 | }) 118 | 119 | vv, ve := version.NewVersion(v) 120 | vw, we := version.NewVersion(w) 121 | 122 | return ve == nil && we == nil && vv.GreaterThan(vw) 123 | } 124 | -------------------------------------------------------------------------------- /pkg/ask/ask.go: -------------------------------------------------------------------------------- 1 | package ask 2 | 3 | import ( 4 | "github.com/AlecAivazis/survey/v2" 5 | 6 | "github.com/algolia/cli/pkg/utils" 7 | ) 8 | 9 | // https://github.com/AlecAivazis/survey#custom-types 10 | type StringSlice struct { 11 | value []string 12 | } 13 | 14 | func (my *StringSlice) WriteAnswer(name string, value interface{}) error { 15 | my.value = utils.StringToSlice(value.(string)) 16 | return nil 17 | } 18 | 19 | func AskCommaSeparatedInputQuestion( 20 | message string, 21 | storage *[]string, 22 | defaultValues []string, 23 | opts ...survey.AskOpt, 24 | ) error { 25 | stringSlice := StringSlice{} 26 | err := survey.AskOne( 27 | &survey.Input{ 28 | Message: message, 29 | Default: utils.SliceToString(defaultValues), 30 | }, 31 | &stringSlice, 32 | ) 33 | *storage = stringSlice.value 34 | 35 | return err 36 | } 37 | 38 | func AskMultiSelectQuestion( 39 | message string, 40 | defaultValues []string, 41 | storage *[]string, 42 | options []string, 43 | opts ...survey.AskOpt, 44 | ) error { 45 | err := survey.AskOne( 46 | &survey.MultiSelect{ 47 | Message: message, 48 | Default: defaultValues, 49 | Options: options, 50 | }, 51 | storage, 52 | ) 53 | 54 | return err 55 | } 56 | 57 | func AskSelectQuestion( 58 | message string, 59 | storage *string, 60 | options []string, 61 | defaultValue string, 62 | opts ...survey.AskOpt, 63 | ) error { 64 | return survey.AskOne(&survey.Select{ 65 | Message: message, 66 | Options: options, 67 | Default: defaultValue, 68 | }, storage, opts...) 69 | } 70 | 71 | func AskInputQuestion( 72 | message string, 73 | storage *string, 74 | defaultValue string, 75 | opts ...survey.AskOpt, 76 | ) error { 77 | return survey.AskOne(&survey.Input{ 78 | Message: message, 79 | Default: defaultValue, 80 | }, storage, opts...) 81 | } 82 | 83 | func AskInputQuestionWithSuggestion( 84 | message string, 85 | storage *string, 86 | defaultValue string, 87 | suggest func(toComplete string) []string, 88 | opts ...survey.AskOpt, 89 | ) error { 90 | return survey.AskOne(&survey.Input{ 91 | Message: message, 92 | Default: defaultValue, 93 | Suggest: suggest, 94 | }, storage, opts...) 95 | } 96 | 97 | func AskBooleanQuestion( 98 | message string, 99 | storage *bool, 100 | defaultValue bool, 101 | opts ...survey.AskOpt, 102 | ) error { 103 | return survey.AskOne(&survey.Confirm{ 104 | Message: message, 105 | Default: defaultValue, 106 | }, storage, opts...) 107 | } 108 | -------------------------------------------------------------------------------- /pkg/auth/auth_check_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/algolia/cli/pkg/httpmock" 9 | "github.com/algolia/cli/test" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_CheckACLs(t *testing.T) { 16 | // Remove these environment variables before the tests 17 | os.Unsetenv("ALGOLIA_APPLICATION_ID") 18 | os.Unsetenv("ALGOLIA_API_KEY") 19 | 20 | tests := []struct { 21 | name string 22 | cmd *cobra.Command 23 | adminKey bool 24 | ACLs []search.Acl 25 | wantErr bool 26 | wantErrMessage string 27 | }{ 28 | { 29 | name: "need no acls", 30 | cmd: &cobra.Command{ 31 | Annotations: map[string]string{}, 32 | }, 33 | adminKey: false, 34 | ACLs: []search.Acl{}, 35 | wantErr: false, 36 | }, 37 | { 38 | name: "need admin key, not admin key", 39 | cmd: &cobra.Command{ 40 | Annotations: map[string]string{ 41 | "acls": "admin", 42 | }, 43 | }, 44 | adminKey: false, 45 | ACLs: []search.Acl{}, 46 | wantErr: true, 47 | wantErrMessage: "this command requires an admin API key. Use the `--api-key` flag with a valid admin API key", 48 | }, 49 | { 50 | name: "need admin key, admin key", 51 | cmd: &cobra.Command{ 52 | Annotations: map[string]string{ 53 | "acls": "admin", 54 | }, 55 | }, 56 | adminKey: true, 57 | ACLs: []search.Acl{}, 58 | wantErr: false, 59 | wantErrMessage: "", 60 | }, 61 | { 62 | name: "need ACLs, missing ACLs", 63 | cmd: &cobra.Command{ 64 | Annotations: map[string]string{ 65 | "acls": "search", 66 | }, 67 | }, 68 | adminKey: false, 69 | ACLs: []search.Acl{}, 70 | wantErr: true, 71 | wantErrMessage: `Missing API key ACL(s): search 72 | Edit your profile or use the ` + "`--api-key`" + ` flag to provide an API key with the missing ACLs. 73 | See https://www.algolia.com/doc/guides/security/api-keys/#rights-and-restrictions for more information`, 74 | }, 75 | { 76 | name: "need ACLs, has ACLs", 77 | cmd: &cobra.Command{ 78 | Annotations: map[string]string{ 79 | "acls": "search", 80 | }, 81 | }, 82 | adminKey: false, 83 | ACLs: []search.Acl{search.ACL_SEARCH}, 84 | wantErr: false, 85 | }, 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | r := httpmock.Registry{} 91 | if tt.adminKey { 92 | r.Register( 93 | httpmock.REST("GET", "1/keys"), 94 | httpmock.JSONResponse(search.ListApiKeysResponse{}), 95 | ) 96 | } else { 97 | r.Register( 98 | httpmock.REST("GET", "1/keys"), 99 | httpmock.ErrorResponse(), 100 | ) 101 | } 102 | 103 | if tt.ACLs != nil && !tt.adminKey { 104 | r.Register( 105 | httpmock.REST("GET", "1/keys/test"), 106 | httpmock.JSONResponse(search.ApiKey{Acl: tt.ACLs}), 107 | ) 108 | } 109 | 110 | f, _ := test.NewFactory(false, &r, nil, "") 111 | f.Config.Profile().APIKey = "test" 112 | 113 | err := CheckACLs(tt.cmd, f) 114 | if tt.wantErr { 115 | assert.EqualError(t, err, tt.wantErrMessage) 116 | } else { 117 | assert.NoError(t, err) 118 | } 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/apikeys.go: -------------------------------------------------------------------------------- 1 | package apikeys 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/apikeys/create" 7 | "github.com/algolia/cli/pkg/cmd/apikeys/delete" 8 | "github.com/algolia/cli/pkg/cmd/apikeys/get" 9 | "github.com/algolia/cli/pkg/cmd/apikeys/list" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | ) 12 | 13 | // NewAPIKeysCmd returns a new command for API Keys. 14 | func NewAPIKeysCmd(f *cmdutil.Factory) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "apikeys", 17 | Aliases: []string{"api-key", "api-keys", "apikey"}, 18 | Short: "Manage your Algolia API keys", 19 | } 20 | 21 | cmd.AddCommand(list.NewListCmd(f, nil)) 22 | cmd.AddCommand(create.NewCreateCmd(f, nil)) 23 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 24 | cmd.AddCommand(get.NewGetCmd(f, nil)) 25 | 26 | return cmd 27 | } 28 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/create/create_test.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/google/shlex" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/algolia/cli/pkg/cmdutil" 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/pkg/iostreams" 15 | "github.com/algolia/cli/test" 16 | ) 17 | 18 | func TestNewCreateCmd(t *testing.T) { 19 | oneHour, _ := time.ParseDuration("1h") 20 | 21 | tests := []struct { 22 | name string 23 | tty bool 24 | cli string 25 | wantsErr bool 26 | wantsOpts CreateOptions 27 | }{ 28 | { 29 | name: "all the flags", 30 | cli: "-i foo,bar --acl search,browse -r \"http://foo.com\" -u 1h -d \"description\"", 31 | tty: false, 32 | wantsErr: false, 33 | wantsOpts: CreateOptions{ 34 | ACL: []string{"search", "browse"}, 35 | Indices: []string{"foo", "bar"}, 36 | Description: "description", 37 | Referers: []string{"http://foo.com"}, 38 | Validity: oneHour, 39 | }, 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | io, _, stdout, stderr := iostreams.Test() 46 | if tt.tty { 47 | io.SetStdinTTY(tt.tty) 48 | io.SetStdoutTTY(tt.tty) 49 | } 50 | 51 | f := &cmdutil.Factory{ 52 | IOStreams: io, 53 | } 54 | 55 | var opts *CreateOptions 56 | cmd := NewCreateCmd(f, func(o *CreateOptions) error { 57 | opts = o 58 | return nil 59 | }) 60 | 61 | args, err := shlex.Split(tt.cli) 62 | require.NoError(t, err) 63 | cmd.SetArgs(args) 64 | _, err = cmd.ExecuteC() 65 | if tt.wantsErr { 66 | assert.Error(t, err) 67 | return 68 | } else { 69 | require.NoError(t, err) 70 | } 71 | 72 | assert.Equal(t, "", stdout.String()) 73 | assert.Equal(t, "", stderr.String()) 74 | 75 | assert.Equal(t, tt.wantsOpts.ACL, opts.ACL) 76 | assert.Equal(t, tt.wantsOpts.Indices, opts.Indices) 77 | assert.Equal(t, tt.wantsOpts.Description, opts.Description) 78 | assert.Equal(t, tt.wantsOpts.Referers, opts.Referers) 79 | assert.Equal(t, tt.wantsOpts.Validity, opts.Validity) 80 | }) 81 | } 82 | } 83 | 84 | func Test_runCreateCmd(t *testing.T) { 85 | tests := []struct { 86 | name string 87 | cli string 88 | isTTY bool 89 | wantOut string 90 | }{ 91 | { 92 | name: "no TTY", 93 | cli: "", 94 | isTTY: false, 95 | wantOut: "", 96 | }, 97 | { 98 | name: "TTY", 99 | cli: "", 100 | isTTY: true, 101 | wantOut: "✓ API key created: foo\n", 102 | }, 103 | } 104 | 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | r := httpmock.Registry{} 108 | r.Register( 109 | httpmock.REST("POST", "1/keys"), 110 | httpmock.JSONResponse(search.AddApiKeyResponse{Key: "foo"}), 111 | ) 112 | 113 | f, out := test.NewFactory(tt.isTTY, &r, nil, "") 114 | cmd := NewCreateCmd(f, nil) 115 | out, err := test.Execute(cmd, tt.cli, out) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | 120 | assert.Equal(t, tt.wantOut, out.String()) 121 | }) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | "github.com/algolia/cli/pkg/config" 11 | "github.com/algolia/cli/pkg/iostreams" 12 | "github.com/algolia/cli/pkg/prompt" 13 | "github.com/algolia/cli/pkg/validators" 14 | ) 15 | 16 | // DeleteOptions represents the options for the create command 17 | type DeleteOptions struct { 18 | config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | SearchClient func() (*search.APIClient, error) 22 | 23 | APIKey string 24 | DoConfirm bool 25 | } 26 | 27 | // NewDeleteCmd returns a new instance of DeleteCmd 28 | func NewDeleteCmd(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { 29 | opts := &DeleteOptions{ 30 | IO: f.IOStreams, 31 | config: f.Config, 32 | SearchClient: f.SearchClient, 33 | } 34 | 35 | var confirm bool 36 | 37 | cmd := &cobra.Command{ 38 | Use: "delete ", 39 | Short: "Deletes the API key", 40 | Args: validators.ExactArgs(1), 41 | Annotations: map[string]string{ 42 | "acls": "admin", 43 | }, 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | opts.APIKey = args[0] 46 | if !confirm { 47 | if !opts.IO.CanPrompt() { 48 | return cmdutil.FlagErrorf( 49 | "--confirm required when non-interactive shell is detected", 50 | ) 51 | } 52 | opts.DoConfirm = true 53 | } 54 | 55 | if runF != nil { 56 | return runF(opts) 57 | } 58 | 59 | return runDeleteCmd(opts) 60 | }, 61 | } 62 | 63 | cmd.Flags(). 64 | BoolVarP(&confirm, "confirm", "y", false, "Skip the delete API key confirmation prompt") 65 | 66 | return cmd 67 | } 68 | 69 | // runDeleteCmd runs the delete command 70 | func runDeleteCmd(opts *DeleteOptions) error { 71 | client, err := opts.SearchClient() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | _, err = client.GetApiKey(client.NewApiGetApiKeyRequest(opts.APIKey)) 77 | if err != nil { 78 | return fmt.Errorf("API key %q does not exist", opts.APIKey) 79 | } 80 | 81 | if opts.DoConfirm { 82 | var confirmed bool 83 | err = prompt.Confirm( 84 | fmt.Sprintf("Delete the following API key: %s?", opts.APIKey), 85 | &confirmed, 86 | ) 87 | if err != nil { 88 | return fmt.Errorf("failed to prompt: %w", err) 89 | } 90 | if !confirmed { 91 | return nil 92 | } 93 | } 94 | 95 | _, err = client.DeleteApiKey(client.NewApiDeleteApiKeyRequest(opts.APIKey)) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | cs := opts.IO.ColorScheme() 101 | if opts.IO.IsStdoutTTY() { 102 | fmt.Fprintf( 103 | opts.IO.Out, 104 | "%s API key successfully deleted: %s\n", 105 | cs.SuccessIcon(), 106 | opts.APIKey, 107 | ) 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/delete/delete_test.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/google/shlex" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/algolia/cli/pkg/cmdutil" 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/pkg/iostreams" 15 | "github.com/algolia/cli/test" 16 | ) 17 | 18 | func TestNewDeleteCmd(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | tty bool 22 | cli string 23 | wantsErr bool 24 | wantsOpts DeleteOptions 25 | }{ 26 | { 27 | name: "no --confirm without tty", 28 | cli: "foo", 29 | tty: false, 30 | wantsErr: true, 31 | wantsOpts: DeleteOptions{ 32 | DoConfirm: true, 33 | APIKey: "foo", 34 | }, 35 | }, 36 | { 37 | name: "--confirm without tty", 38 | cli: "foo --confirm", 39 | tty: false, 40 | wantsErr: false, 41 | wantsOpts: DeleteOptions{ 42 | DoConfirm: false, 43 | APIKey: "foo", 44 | }, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | io, _, stdout, stderr := iostreams.Test() 51 | if tt.tty { 52 | io.SetStdinTTY(tt.tty) 53 | io.SetStdoutTTY(tt.tty) 54 | } 55 | 56 | f := &cmdutil.Factory{ 57 | IOStreams: io, 58 | } 59 | 60 | var opts *DeleteOptions 61 | cmd := NewDeleteCmd(f, func(o *DeleteOptions) error { 62 | opts = o 63 | return nil 64 | }) 65 | 66 | args, err := shlex.Split(tt.cli) 67 | require.NoError(t, err) 68 | cmd.SetArgs(args) 69 | _, err = cmd.ExecuteC() 70 | if tt.wantsErr { 71 | assert.Error(t, err) 72 | return 73 | } else { 74 | require.NoError(t, err) 75 | } 76 | 77 | assert.Equal(t, "", stdout.String()) 78 | assert.Equal(t, "", stderr.String()) 79 | 80 | assert.Equal(t, tt.wantsOpts.APIKey, opts.APIKey) 81 | assert.Equal(t, tt.wantsOpts.DoConfirm, opts.DoConfirm) 82 | }) 83 | } 84 | } 85 | 86 | func Test_runDeleteCmd(t *testing.T) { 87 | tests := []struct { 88 | name string 89 | cli string 90 | key string 91 | isTTY bool 92 | wantOut string 93 | }{ 94 | { 95 | name: "one key", 96 | cli: "foo --confirm", 97 | key: "foo", 98 | isTTY: false, 99 | wantOut: "", 100 | }, 101 | } 102 | 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | r := httpmock.Registry{} 106 | r.Register( 107 | httpmock.REST("GET", fmt.Sprintf("1/keys/%s", tt.key)), 108 | httpmock.JSONResponse(search.GetApiKeyResponse{Value: "foo"}), 109 | ) 110 | r.Register( 111 | httpmock.REST("DELETE", fmt.Sprintf("1/keys/%s", tt.key)), 112 | httpmock.JSONResponse(search.DeletedAtResponse{}), 113 | ) 114 | 115 | f, out := test.NewFactory(tt.isTTY, &r, nil, "") 116 | cmd := NewDeleteCmd(f, nil) 117 | out, err := test.Execute(cmd, tt.cli, out) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | assert.Equal(t, tt.wantOut, out.String()) 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/validators" 14 | ) 15 | 16 | // GetOptions represents the options for the get command 17 | type GetOptions struct { 18 | config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | SearchClient func() (*search.APIClient, error) 22 | 23 | APIKey string 24 | 25 | PrintFlags *cmdutil.PrintFlags 26 | } 27 | 28 | // NewGetCmd returns a new instance of the command 29 | func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { 30 | opts := &GetOptions{ 31 | IO: f.IOStreams, 32 | config: f.Config, 33 | SearchClient: f.SearchClient, 34 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 35 | } 36 | 37 | cmd := &cobra.Command{ 38 | Use: "get ", 39 | Short: "Get the API key", 40 | Long: heredoc.Doc(` 41 | Get the details of a given API Key (ACLs, description, indexes, and other attributes). 42 | `), 43 | Example: heredoc.Doc(` 44 | # Get an API key 45 | $ algolia --application-id app-id apikeys get abcdef1234567890 46 | `), 47 | Args: validators.ExactArgs(1), 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | opts.APIKey = args[0] 50 | 51 | if runF != nil { 52 | return runF(opts) 53 | } 54 | 55 | return runGetCmd(opts) 56 | }, 57 | } 58 | 59 | return cmd 60 | } 61 | 62 | // runGetCmd runs the get command 63 | func runGetCmd(opts *GetOptions) error { 64 | opts.config.Profile().APIKey = opts.APIKey 65 | client, err := opts.SearchClient() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | key, err := client.GetApiKey(client.NewApiGetApiKeyRequest(opts.APIKey)) 71 | if err != nil { 72 | return fmt.Errorf("API key %q does not exist", opts.APIKey) 73 | } 74 | 75 | p, err := opts.PrintFlags.ToPrinter() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | if err := p.Print(opts.IO, key); err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/get/get_test.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runGetCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | key string 17 | wantErr string 18 | }{ 19 | { 20 | name: "get a key (success)", 21 | key: "foo", 22 | }, 23 | { 24 | name: "get a key (error)", 25 | key: "bar", 26 | wantErr: "API key \"bar\" does not exist", 27 | }, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | r := httpmock.Registry{} 33 | if tt.key == "foo" { 34 | name := "test" 35 | r.Register( 36 | httpmock.REST("GET", "1/keys/foo"), 37 | httpmock.JSONResponse(search.GetApiKeyResponse{ 38 | Value: "foo", 39 | Description: &name, 40 | Acl: []search.Acl{search.ACL_SEARCH}, 41 | }), 42 | ) 43 | } else { 44 | r.Register( 45 | httpmock.REST("GET", "1/keys/bar"), 46 | httpmock.ErrorResponse(), 47 | ) 48 | } 49 | 50 | f, out := test.NewFactory(false, &r, nil, "") 51 | cmd := NewGetCmd(f, nil) 52 | _, err := test.Execute(cmd, tt.key, out) 53 | if err != nil { 54 | assert.Equal(t, tt.wantErr, err.Error()) 55 | return 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/apikeys/list/list_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runListCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | isTTY bool 17 | wantOut string 18 | }{ 19 | { 20 | name: "list", 21 | isTTY: false, 22 | wantOut: "foo\ttest\t[search]\t[]\tNever expire\t0\t0\t[]\t5 years ago\n", 23 | }, 24 | { 25 | name: "list_tty", 26 | isTTY: true, 27 | wantOut: "KEY DESCRIPTION ACL INDICES VALI... MAX ... MAX ... REFE... CREA...\nfoo test [sea... [] Neve... 0 0 [] 5 ye...\n", 28 | }, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | name := "test" 34 | r := httpmock.Registry{} 35 | r.Register( 36 | httpmock.REST("GET", "1/keys"), 37 | httpmock.JSONResponse(search.ListApiKeysResponse{ 38 | Keys: []search.GetApiKeyResponse{ 39 | { 40 | Value: "foo", 41 | Description: &name, 42 | Acl: []search.Acl{search.ACL_SEARCH}, 43 | CreatedAt: 1577836800, 44 | }, 45 | }, 46 | }), 47 | ) 48 | 49 | f, out := test.NewFactory(tt.isTTY, &r, nil, "") 50 | cmd := NewListCmd(f, nil) 51 | out, err := test.Execute(cmd, "", out) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | assert.Equal(t, tt.wantOut, out.String()) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/crawler.go: -------------------------------------------------------------------------------- 1 | package crawler 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/pkg/cmd/crawler/crawl" 11 | "github.com/algolia/cli/pkg/cmd/crawler/create" 12 | "github.com/algolia/cli/pkg/cmd/crawler/get" 13 | "github.com/algolia/cli/pkg/cmd/crawler/list" 14 | "github.com/algolia/cli/pkg/cmd/crawler/pause" 15 | "github.com/algolia/cli/pkg/cmd/crawler/reindex" 16 | "github.com/algolia/cli/pkg/cmd/crawler/run" 17 | "github.com/algolia/cli/pkg/cmd/crawler/stats" 18 | "github.com/algolia/cli/pkg/cmd/crawler/test" 19 | "github.com/algolia/cli/pkg/cmd/crawler/unblock" 20 | "github.com/algolia/cli/pkg/cmdutil" 21 | ) 22 | 23 | const ( 24 | AuthMethodHelpMsg = `In order to use the 'crawler' commands, you will need to authenticate with the Algolia Crawler API. You can do so by either: 25 | - Export your Algolia Crawler username and API Key as ALGOLIA_CRAWLER_USER_ID and ALGOLIA_CRAWLER_API_KEY environment variables. 26 | - Add your Algolia Crawler 'crawler_user_id' and 'crawler_api_key' credentials to your profile file (~/.config/algolia/config.tml).` 27 | ) 28 | 29 | // NewCrawlersCmd returns a new command to manage your Algolia Crawlers. 30 | func NewCrawlersCmd(f *cmdutil.Factory) *cobra.Command { 31 | cmd := &cobra.Command{ 32 | Use: "crawler", 33 | Aliases: []string{"crawlers"}, 34 | Short: "Manage your Algolia crawlers", 35 | Long: heredoc.Docf(` 36 | Manage your Algolia crawlers. 37 | 38 | %s 39 | `, AuthMethodHelpMsg), 40 | // Check Crawler specific Authentication 41 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 42 | _, err := f.CrawlerClient() 43 | if err != nil { 44 | authError := errors.New("authError") 45 | stderr := f.IOStreams.ErrOut 46 | fmt.Fprintf(stderr, "Crawler authentication error: %s\n", err) 47 | fmt.Fprintln(stderr, "") 48 | fmt.Fprintln(stderr, AuthMethodHelpMsg) 49 | return authError 50 | } 51 | return nil 52 | }, 53 | } 54 | 55 | cmd.AddCommand(list.NewListCmd(f, nil)) 56 | cmd.AddCommand(reindex.NewReindexCmd(f, nil)) 57 | cmd.AddCommand(stats.NewStatsCmd(f, nil)) 58 | cmd.AddCommand(unblock.NewUnblockCmd(f, nil)) 59 | cmd.AddCommand(run.NewRunCmd(f, nil)) 60 | cmd.AddCommand(pause.NewPauseCmd(f, nil)) 61 | cmd.AddCommand(crawl.NewCrawlCmd(f, nil)) 62 | cmd.AddCommand(test.NewTestCmd(f, nil)) 63 | cmd.AddCommand(get.NewGetCmd(f, nil)) 64 | cmd.AddCommand(create.NewCreateCmd(f, nil)) 65 | 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/create/create.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/api/crawler" 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | "github.com/algolia/cli/pkg/config" 13 | "github.com/algolia/cli/pkg/iostreams" 14 | ) 15 | 16 | type CreateOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | CrawlerClient func() (*crawler.Client, error) 21 | 22 | Name string 23 | config crawler.Config 24 | } 25 | 26 | // NewCreateCmd creates and returns a create command for Crawlers. 27 | func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { 28 | opts := &CreateOptions{ 29 | IO: f.IOStreams, 30 | Config: f.Config, 31 | CrawlerClient: f.CrawlerClient, 32 | } 33 | 34 | var configFile string 35 | 36 | cmd := &cobra.Command{ 37 | Use: "create -F ", 38 | Aliases: []string{"new", "n", "c"}, 39 | Args: cobra.ExactArgs(1), 40 | Short: "Create a crawler", 41 | Long: heredoc.Doc(` 42 | Create a new crawler from the given configuration. 43 | `), 44 | Example: heredoc.Doc(` 45 | # Create a crawler named "my-crawler" with the configuration in the file "config.json" 46 | $ algolia crawler create my-crawler -F config.json 47 | 48 | # Create a crawler from another crawler's configuration 49 | $ algolia crawler get another-crawler --config-only | algolia crawler create my-crawler -F - 50 | `), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | opts.Name = args[0] 53 | 54 | b, err := cmdutil.ReadFile(configFile, opts.IO.In) 55 | if err != nil { 56 | return err 57 | } 58 | err = json.Unmarshal(b, &opts.config) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if runF != nil { 64 | return runF(opts) 65 | } 66 | 67 | return runCreateCmd(opts) 68 | }, 69 | } 70 | 71 | cmd.Flags(). 72 | StringVarP(&configFile, "file", "F", "", "Path to the configuration file (use \"-\" to read from standard input)") 73 | _ = cmd.MarkFlagRequired("file") 74 | 75 | return cmd 76 | } 77 | 78 | func runCreateCmd(opts *CreateOptions) error { 79 | client, err := opts.CrawlerClient() 80 | if err != nil { 81 | return err 82 | } 83 | cs := opts.IO.ColorScheme() 84 | 85 | opts.IO.StartProgressIndicatorWithLabel("Creating crawler") 86 | id, err := client.Create(opts.Name, opts.config) 87 | opts.IO.StopProgressIndicator() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if opts.IO.IsStdoutTTY() { 93 | fmt.Fprintf( 94 | opts.IO.Out, 95 | "%s Crawler %s created: %s\n", 96 | cs.SuccessIconWithColor(cs.Green), 97 | cs.Bold(opts.Name), 98 | cs.Bold(id), 99 | ) 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/create/create_test.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/google/shlex" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/algolia/cli/api/crawler" 14 | "github.com/algolia/cli/pkg/cmdutil" 15 | "github.com/algolia/cli/pkg/httpmock" 16 | "github.com/algolia/cli/pkg/iostreams" 17 | "github.com/algolia/cli/test" 18 | ) 19 | 20 | func TestNewCreateCmd(t *testing.T) { 21 | tmpFile := filepath.Join(t.TempDir(), "config.json") 22 | err := os.WriteFile(tmpFile, []byte("{\"enableReRanking\":false}"), 0o600) 23 | require.NoError(t, err) 24 | 25 | tests := []struct { 26 | name string 27 | tty bool 28 | cli string 29 | wantsErr bool 30 | wantsOpts CreateOptions 31 | }{ 32 | { 33 | name: "no tty", 34 | cli: fmt.Sprintf("my-crawler -F '%s'", tmpFile), 35 | tty: false, 36 | wantsErr: false, 37 | wantsOpts: CreateOptions{ 38 | Name: "my-crawler", 39 | config: crawler.Config{}, 40 | }, 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | io, _, stdout, stderr := iostreams.Test() 47 | if tt.tty { 48 | io.SetStdinTTY(tt.tty) 49 | io.SetStdoutTTY(tt.tty) 50 | } 51 | 52 | f := &cmdutil.Factory{ 53 | IOStreams: io, 54 | } 55 | 56 | var opts *CreateOptions 57 | cmd := NewCreateCmd(f, func(o *CreateOptions) error { 58 | opts = o 59 | return nil 60 | }) 61 | 62 | args, err := shlex.Split(tt.cli) 63 | require.NoError(t, err) 64 | cmd.SetArgs(args) 65 | _, err = cmd.ExecuteC() 66 | if tt.wantsErr { 67 | assert.Error(t, err) 68 | return 69 | } else { 70 | require.NoError(t, err) 71 | } 72 | 73 | assert.Equal(t, "", stdout.String()) 74 | assert.Equal(t, "", stderr.String()) 75 | 76 | assert.Equal(t, tt.wantsOpts.Name, opts.Name) 77 | assert.Equal(t, tt.wantsOpts.config, opts.config) 78 | }) 79 | } 80 | } 81 | 82 | func Test_runCreateCmd(t *testing.T) { 83 | tmpFile := filepath.Join(t.TempDir(), "config.json") 84 | err := os.WriteFile(tmpFile, []byte("{\"enableReRanking\":false}"), 0o600) 85 | require.NoError(t, err) 86 | 87 | tests := []struct { 88 | name string 89 | cli string 90 | isTTY bool 91 | wantOut string 92 | }{ 93 | { 94 | name: "no tty", 95 | cli: fmt.Sprintf("my-crawler -F '%s'", tmpFile), 96 | isTTY: false, 97 | wantOut: "", 98 | }, 99 | { 100 | name: "tty", 101 | cli: fmt.Sprintf("my-crawler -F '%s'", tmpFile), 102 | isTTY: true, 103 | wantOut: "✓ Crawler my-crawler created: crawler-id\n", 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | r := httpmock.Registry{} 110 | res := crawler.Crawler{ID: "crawler-id"} 111 | r.Register(httpmock.REST("POST", "api/1/crawlers"), httpmock.JSONResponse(res)) 112 | defer r.Verify(t) 113 | 114 | f, out := test.NewFactory(tt.isTTY, &r, nil, "") 115 | cmd := NewCreateCmd(f, nil) 116 | out, err := test.Execute(cmd, tt.cli, out) 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | 121 | assert.Equal(t, tt.wantOut, out.String()) 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | ) 14 | 15 | type GetOptions struct { 16 | Config config.IConfig 17 | IO *iostreams.IOStreams 18 | 19 | CrawlerClient func() (*crawler.Client, error) 20 | 21 | ID string 22 | ConfigOnly bool 23 | 24 | PrintFlags *cmdutil.PrintFlags 25 | } 26 | 27 | // NewGetCmd creates and returns a get command for Crawlers. 28 | func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { 29 | opts := &GetOptions{ 30 | IO: f.IOStreams, 31 | Config: f.Config, 32 | CrawlerClient: f.CrawlerClient, 33 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 34 | } 35 | cmd := &cobra.Command{ 36 | Use: "get ", 37 | Args: cobra.ExactArgs(1), 38 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 39 | Short: "Get a crawler", 40 | Long: heredoc.Doc(` 41 | Get the specified crawler. 42 | `), 43 | Example: heredoc.Doc(` 44 | # Get the crawler with the ID "my-crawler" 45 | $ algolia crawler get my-crawler 46 | 47 | # Get the crawler with the ID "my-crawler" and display only its configuration 48 | $ algolia crawler get my-crawler --config-only 49 | `), 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | opts.ID = args[0] 52 | if runF != nil { 53 | return runF(opts) 54 | } 55 | 56 | return runGetCmd(opts) 57 | }, 58 | } 59 | 60 | cmd.Flags(). 61 | BoolVarP(&opts.ConfigOnly, "config-only", "c", false, "Display only the crawler configuration") 62 | 63 | return cmd 64 | } 65 | 66 | func runGetCmd(opts *GetOptions) error { 67 | client, err := opts.CrawlerClient() 68 | if err != nil { 69 | return err 70 | } 71 | 72 | opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching crawler %s", opts.ID)) 73 | crawler, err := client.Get(opts.ID, true) 74 | opts.IO.StopProgressIndicator() 75 | if err != nil { 76 | return err 77 | } 78 | 79 | var toPrint interface{} 80 | if opts.ConfigOnly { 81 | toPrint = crawler.Config 82 | } else { 83 | toPrint = crawler 84 | } 85 | 86 | p, err := opts.PrintFlags.ToPrinter() 87 | if err != nil { 88 | return err 89 | } 90 | if err := p.Print(opts.IO, toPrint); err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/pause/pause.go: -------------------------------------------------------------------------------- 1 | package pause 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/utils" 14 | ) 15 | 16 | type PauseOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | CrawlerClient func() (*crawler.Client, error) 21 | 22 | IDs []string 23 | } 24 | 25 | // NewPauseCmd creates and returns a pause command for Crawlers. 26 | func NewPauseCmd(f *cmdutil.Factory, runF func(*PauseOptions) error) *cobra.Command { 27 | opts := &PauseOptions{ 28 | IO: f.IOStreams, 29 | Config: f.Config, 30 | CrawlerClient: f.CrawlerClient, 31 | } 32 | cmd := &cobra.Command{ 33 | Use: "pause ...", 34 | Args: cobra.MinimumNArgs(1), 35 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 36 | Short: "Pause one or multiple crawlers", 37 | Long: heredoc.Doc(` 38 | Pauses the specified crawler. 39 | `), 40 | Example: heredoc.Doc(` 41 | # Pause the crawler with the ID "my-crawler" 42 | $ algolia crawler pause my-crawler 43 | 44 | # Pause the crawlers with the IDs "my-crawler-1" and "my-crawler-2" 45 | $ algolia crawler pause my-crawler-1 my-crawler-2 46 | `), 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | opts.IDs = args 49 | if runF != nil { 50 | return runF(opts) 51 | } 52 | 53 | return runPauseCmd(opts) 54 | }, 55 | } 56 | 57 | return cmd 58 | } 59 | 60 | func runPauseCmd(opts *PauseOptions) error { 61 | client, err := opts.CrawlerClient() 62 | if err != nil { 63 | return err 64 | } 65 | cs := opts.IO.ColorScheme() 66 | 67 | opts.IO.StartProgressIndicatorWithLabel( 68 | fmt.Sprintf("Pausing %s", utils.Pluralize(len(opts.IDs), "crawler")), 69 | ) 70 | for _, id := range opts.IDs { 71 | if _, err := client.Reindex(id); err != nil { 72 | opts.IO.StopProgressIndicator() 73 | return fmt.Errorf("cannot pause crawler %s: %w", cs.Bold(id), err) 74 | } 75 | } 76 | opts.IO.StopProgressIndicator() 77 | 78 | if opts.IO.IsStdoutTTY() { 79 | fmt.Fprintf( 80 | opts.IO.Out, 81 | "%s %s\n", 82 | cs.SuccessIconWithColor(cs.Green), 83 | fmt.Sprintf("Successfully paused %s", utils.Pluralize(len(opts.IDs), "crawler")), 84 | ) 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/reindex/reindex.go: -------------------------------------------------------------------------------- 1 | package reindex 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/utils" 14 | ) 15 | 16 | type ReindexOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | CrawlerClient func() (*crawler.Client, error) 21 | 22 | IDs []string 23 | } 24 | 25 | // NewReindexCmd creates and returns a reindex command for Crawlers. 26 | func NewReindexCmd(f *cmdutil.Factory, runF func(*ReindexOptions) error) *cobra.Command { 27 | opts := &ReindexOptions{ 28 | IO: f.IOStreams, 29 | Config: f.Config, 30 | CrawlerClient: f.CrawlerClient, 31 | } 32 | cmd := &cobra.Command{ 33 | Use: "reindex ...", 34 | Args: cobra.MinimumNArgs(1), 35 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 36 | Short: "Reindexes the specified crawlers", 37 | Long: heredoc.Doc(` 38 | Request the specified crawler to start (or restart) crawling. 39 | `), 40 | Example: heredoc.Doc(` 41 | # Reindex the crawler with the ID "my-crawler" 42 | $ algolia crawler reindex my-crawler 43 | 44 | # Reindex the crawlers with the IDs "my-crawler-1" and "my-crawler-2" 45 | $ algolia crawler reindex my-crawler-1 my-crawler-2 46 | `), 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | opts.IDs = args 49 | if runF != nil { 50 | return runF(opts) 51 | } 52 | 53 | return runReindexCmd(opts) 54 | }, 55 | } 56 | 57 | return cmd 58 | } 59 | 60 | func runReindexCmd(opts *ReindexOptions) error { 61 | client, err := opts.CrawlerClient() 62 | if err != nil { 63 | return err 64 | } 65 | cs := opts.IO.ColorScheme() 66 | 67 | opts.IO.StartProgressIndicatorWithLabel( 68 | fmt.Sprintf("Reindexing %s", utils.Pluralize(len(opts.IDs), "crawler")), 69 | ) 70 | for _, id := range opts.IDs { 71 | if _, err := client.Reindex(id); err != nil { 72 | opts.IO.StopProgressIndicator() 73 | return fmt.Errorf("cannot reindex crawler %s: %w", cs.Bold(id), err) 74 | } 75 | } 76 | opts.IO.StopProgressIndicator() 77 | 78 | if opts.IO.IsStdoutTTY() { 79 | fmt.Fprintf( 80 | opts.IO.Out, 81 | "%s %s\n", 82 | cs.SuccessIconWithColor(cs.Green), 83 | fmt.Sprintf( 84 | "Successfully requested reindexing for %s", 85 | utils.Pluralize(len(opts.IDs), "crawler"), 86 | ), 87 | ) 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/run/run.go: -------------------------------------------------------------------------------- 1 | package run 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | ) 14 | 15 | // RunOptions holds the options for the stats command. 16 | type RunOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | CrawlerClient func() (*crawler.Client, error) 21 | 22 | ID string 23 | } 24 | 25 | // NewRunCmd creates and returns a run command for crawlers. 26 | func NewRunCmd(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command { 27 | opts := &RunOptions{ 28 | IO: f.IOStreams, 29 | Config: f.Config, 30 | CrawlerClient: f.CrawlerClient, 31 | } 32 | cmd := &cobra.Command{ 33 | Use: "run ", 34 | Args: cobra.ExactArgs(1), 35 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 36 | Short: "Start or resume a crawler", 37 | Long: heredoc.Doc(` 38 | Unpause the specified crawler. 39 | Previously ongoing crawls will be resumed. Otherwise, the crawler waits for its next scheduled run. 40 | `), 41 | Example: heredoc.Doc(` 42 | # Run the crawler with the ID "my-crawler" 43 | $ algolia crawler run my-crawler 44 | `), 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | opts.ID = args[0] 47 | if runF != nil { 48 | return runF(opts) 49 | } 50 | 51 | return runRunCmd(opts) 52 | }, 53 | } 54 | 55 | return cmd 56 | } 57 | 58 | func runRunCmd(opts *RunOptions) error { 59 | client, err := opts.CrawlerClient() 60 | if err != nil { 61 | return err 62 | } 63 | cs := opts.IO.ColorScheme() 64 | 65 | _, err = client.Run(opts.ID) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if opts.IO.IsStdoutTTY() { 71 | fmt.Fprintf( 72 | opts.IO.Out, 73 | "%s Crawler %s started\n", 74 | cs.SuccessIconWithColor(cs.Green), 75 | opts.ID, 76 | ) 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/stats/stats.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/printers" 14 | ) 15 | 16 | // StatsOptions holds the options for the stats command. 17 | type StatsOptions struct { 18 | Config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | CrawlerClient func() (*crawler.Client, error) 22 | 23 | ID string 24 | 25 | PrintFlags *cmdutil.PrintFlags 26 | } 27 | 28 | // NewStatsCmd creates and returns a stats command for crawlers. 29 | func NewStatsCmd(f *cmdutil.Factory, runF func(*StatsOptions) error) *cobra.Command { 30 | opts := &StatsOptions{ 31 | IO: f.IOStreams, 32 | Config: f.Config, 33 | CrawlerClient: f.CrawlerClient, 34 | PrintFlags: cmdutil.NewPrintFlags(), 35 | } 36 | cmd := &cobra.Command{ 37 | Use: "stats ", 38 | Args: cobra.ExactArgs(1), 39 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 40 | Short: "Get statistics about a crawler", 41 | Long: heredoc.Doc(` 42 | Get a summary of the current status of crawled URLs for the specified crawler. 43 | `), 44 | Example: heredoc.Doc(` 45 | # Get statistics about the crawler with the ID "my-crawler" 46 | $ algolia crawler stats my-crawler 47 | `), 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | opts.ID = args[0] 50 | if runF != nil { 51 | return runF(opts) 52 | } 53 | 54 | return runStatsCmd(opts) 55 | }, 56 | } 57 | 58 | opts.PrintFlags.AddFlags(cmd) 59 | 60 | return cmd 61 | } 62 | 63 | func runStatsCmd(opts *StatsOptions) error { 64 | client, err := opts.CrawlerClient() 65 | if err != nil { 66 | return err 67 | } 68 | cs := opts.IO.ColorScheme() 69 | 70 | stats, err := client.Stats(opts.ID) 71 | if err != nil { 72 | return fmt.Errorf("cannot get stats: %w", err) 73 | } 74 | 75 | if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { 76 | p, err := opts.PrintFlags.ToPrinter() 77 | if err != nil { 78 | return err 79 | } 80 | 81 | if err := p.Print(opts.IO, stats); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | table := printers.NewTablePrinter(opts.IO) 89 | if table.IsTTY() { 90 | table.AddField("STATUS", nil, nil) 91 | table.AddField("CATEGORY", nil, nil) 92 | table.AddField("REASON", nil, nil) 93 | table.AddField("COUNT", nil, nil) 94 | 95 | table.EndRow() 96 | } 97 | 98 | status := func(s string) func(string) string { 99 | switch s { 100 | case "DONE": 101 | return cs.Green 102 | case "SKIPPED": 103 | return cs.Gray 104 | case "FAILED": 105 | return cs.Red 106 | default: 107 | return cs.Gray 108 | } 109 | } 110 | 111 | for _, stat := range stats.Data { 112 | table.AddField(status(stat.Status)(stat.Status), nil, nil) 113 | table.AddField(stat.Category, nil, nil) 114 | table.AddField(stat.Reason, nil, nil) 115 | table.AddField(fmt.Sprintf("%d", stat.Count), nil, nil) 116 | 117 | table.EndRow() 118 | } 119 | return table.Render() 120 | } 121 | -------------------------------------------------------------------------------- /pkg/cmd/crawler/unblock/unblock.go: -------------------------------------------------------------------------------- 1 | package unblock 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/api/crawler" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/prompt" 14 | ) 15 | 16 | // UnblockOptions holds the options for the stats command. 17 | type UnblockOptions struct { 18 | Config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | CrawlerClient func() (*crawler.Client, error) 22 | 23 | ID string 24 | DoConfirm bool 25 | 26 | PrintFlags *cmdutil.PrintFlags 27 | } 28 | 29 | // NewUnblockCmd creates and returns a command to unblock a crawler. 30 | func NewUnblockCmd(f *cmdutil.Factory, runF func(*UnblockOptions) error) *cobra.Command { 31 | opts := &UnblockOptions{ 32 | IO: f.IOStreams, 33 | Config: f.Config, 34 | CrawlerClient: f.CrawlerClient, 35 | } 36 | 37 | var confirm bool 38 | 39 | cmd := &cobra.Command{ 40 | Use: "unblock ", 41 | Args: cobra.ExactArgs(1), 42 | ValidArgsFunction: cmdutil.CrawlerIDs(opts.CrawlerClient), 43 | Short: "Unblock a crawler", 44 | Long: heredoc.Doc(` 45 | Unblock a crawler by cancelling the specific task that is currently blocking it. 46 | `), 47 | Example: heredoc.Doc(` 48 | # Unblock the crawler with the ID "my-crawler" 49 | $ algolia crawler unblock my-crawler 50 | `), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | opts.ID = args[0] 53 | if runF != nil { 54 | return runF(opts) 55 | } 56 | 57 | if !confirm { 58 | if !opts.IO.CanPrompt() { 59 | return cmdutil.FlagErrorf( 60 | "--confirm required when non-interactive shell is detected", 61 | ) 62 | } 63 | opts.DoConfirm = true 64 | } 65 | 66 | return runUnblockCmd(opts) 67 | }, 68 | } 69 | 70 | return cmd 71 | } 72 | 73 | // runUnblockCmd executes the unblock command. 74 | func runUnblockCmd(opts *UnblockOptions) error { 75 | client, err := opts.CrawlerClient() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // Get the crawler and check if it is actually blocked. 81 | crawler, err := client.Get(opts.ID, false) 82 | if err != nil { 83 | return err 84 | } 85 | if crawler.BlockingTaskID == "" { 86 | return fmt.Errorf("crawler %q is not blocked", opts.ID) 87 | } 88 | 89 | if opts.DoConfirm { 90 | var confirmed bool 91 | err := prompt.Confirm( 92 | fmt.Sprintf( 93 | "Are you sure you want to unblock the crawler %q? \nBlocking error is: %s", 94 | opts.ID, 95 | crawler.BlockingError, 96 | ), 97 | &confirmed, 98 | ) 99 | if err != nil { 100 | return fmt.Errorf("failed to prompt: %w", err) 101 | } 102 | if !confirmed { 103 | return nil 104 | } 105 | } 106 | 107 | // Cancel the task blocking the crawler. 108 | if err := client.CancelTask(crawler.ID, crawler.BlockingTaskID); err != nil { 109 | return err 110 | } 111 | 112 | cs := opts.IO.ColorScheme() 113 | if opts.IO.IsStdoutTTY() { 114 | fmt.Fprintf(opts.IO.Out, "%s Unblocked crawler %s\n", cs.SuccessIcon(), cs.Bold(opts.ID)) 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/dictionary.go: -------------------------------------------------------------------------------- 1 | package dictionary 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/spf13/cobra" 6 | 7 | "github.com/algolia/cli/pkg/cmd/dictionary/entries" 8 | "github.com/algolia/cli/pkg/cmd/dictionary/settings" 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | ) 11 | 12 | // NewDictionaryCmd returns a new command for dictionaries. 13 | func NewDictionaryCmd(f *cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "dictionary", 16 | Aliases: []string{"dictionaries", "dict"}, 17 | Short: "Manage your Algolia dictionaries", 18 | Annotations: map[string]string{ 19 | "help:see-also": heredoc.Doc(` 20 | The below command examples are not directly related to the dictionary command, but are relevant to the use of dictionaries in general. 21 | 22 | # Open Algolia supported languages page 23 | $ algolia open languages 24 | 25 | # Set the 'ignorePlurals' setting to true: https://www.algolia.com/doc/api-reference/api-parameters/ignorePlurals/ 26 | $ algolia settings set --ignorePlurals 27 | 28 | # Set the 'removeStopWords' setting to true: https://www.algolia.com/doc/api-reference/api-parameters/removeStopWords/ 29 | $ algolia settings set --removeStopWords 30 | 31 | # Set the 'decompoundQuery' setting to true: https://www.algolia.com/doc/api-reference/api-parameters/decompoundQuery/ 32 | $ algolia settings set --decompoundQuery 33 | `), 34 | }, 35 | } 36 | 37 | cmd.AddCommand(settings.NewSettingsCmd(f)) 38 | cmd.AddCommand(entries.NewEntriesCmd(f)) 39 | 40 | return cmd 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/entries/entries.go: -------------------------------------------------------------------------------- 1 | package entries 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/dictionary/entries/browse" 7 | "github.com/algolia/cli/pkg/cmd/dictionary/entries/clear" 8 | "github.com/algolia/cli/pkg/cmd/dictionary/entries/delete" 9 | importentries "github.com/algolia/cli/pkg/cmd/dictionary/entries/import" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | ) 12 | 13 | // NewEntriesCmd returns a new command for dictionary entries. 14 | func NewEntriesCmd(f *cmdutil.Factory) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "entries", 17 | Short: "Manage your Algolia dictionary entries", 18 | } 19 | 20 | cmd.AddCommand(clear.NewClearCmd(f, nil)) 21 | cmd.AddCommand(browse.NewBrowseCmd(f, nil)) 22 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 23 | cmd.AddCommand(importentries.NewImportCmd(f, nil)) 24 | 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/settings/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/algolia/cli/pkg/cmdutil" 9 | "github.com/algolia/cli/pkg/config" 10 | "github.com/algolia/cli/pkg/iostreams" 11 | ) 12 | 13 | type GetOptions struct { 14 | Config config.IConfig 15 | IO *iostreams.IOStreams 16 | 17 | SearchClient func() (*search.APIClient, error) 18 | 19 | PrintFlags *cmdutil.PrintFlags 20 | } 21 | 22 | // NewGetCmd creates and returns a get command for dictionaries' settings. 23 | func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { 24 | opts := &GetOptions{ 25 | IO: f.IOStreams, 26 | Config: f.Config, 27 | SearchClient: f.SearchClient, 28 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 29 | } 30 | cmd := &cobra.Command{ 31 | Use: "get", 32 | Args: cobra.NoArgs, 33 | Annotations: map[string]string{ 34 | "acls": "settings", 35 | }, 36 | Short: "Get the dictionary settings", 37 | Long: heredoc.Doc(` 38 | Retrieve the dictionary override settings for plurals, stop words, and compound words. 39 | `), 40 | Example: heredoc.Doc(` 41 | # Get the dictionary settings 42 | $ algolia dictionary settings get 43 | `), 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | if runF != nil { 46 | return runF(opts) 47 | } 48 | 49 | return runGetCmd(opts) 50 | }, 51 | } 52 | 53 | opts.PrintFlags.AddFlags(cmd) 54 | 55 | return cmd 56 | } 57 | 58 | // runGetCmd executes the get command 59 | func runGetCmd(opts *GetOptions) error { 60 | client, err := opts.SearchClient() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | p, err := opts.PrintFlags.ToPrinter() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | res, err := client.GetDictionarySettings() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return p.Print(opts.IO, res) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/settings/set/languages.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | // The v4 API client doesn't have the long form of language names 4 | var Languages = map[string]string{ 5 | "af": "Afrikaans", 6 | "sq": "Albanian", 7 | "ar": "Arabic", 8 | "hy": "Armenian", 9 | "az": "Azerbaijani", 10 | "eu": "Basque", 11 | "bn": "Bengali", 12 | "pt-br": "Brazilian Portuguese", 13 | "bg": "Bulgarian", 14 | "ca": "Catalan", 15 | "zh": "Chinese", 16 | "cs": "Czech", 17 | "da": "Danish", 18 | "nl": "Dutch", 19 | "en": "English", 20 | "eo": "Esperanto", 21 | "et": "Estonian", 22 | "fo": "Faroese", 23 | "fi": "Finnish", 24 | "fr": "French", 25 | "gl": "Galician", 26 | "ka": "Georgian", 27 | "de": "German", 28 | "el": "Greek", 29 | "he": "Hebrew", 30 | "hi": "Hindi", 31 | "hu": "Hungarian", 32 | "is": "Icelandic", 33 | "id": "Indonesian", 34 | "ga": "Irish", 35 | "it": "Italian", 36 | "ja": "Japanese", 37 | "kk": "Kazakh", 38 | "ky": "Kyrgyz", 39 | "ko": "Korean", 40 | "ku": "Kurdish", 41 | "lv": "Latvian", 42 | "lt": "Lithuanian", 43 | "ms": "Malay", 44 | "mt": "Maltese", 45 | "mi": "Maori", 46 | "mr": "Marathi", 47 | "mn": "Mongolian", 48 | "ns": "Northern Sotho", 49 | "no": "Norwegian", 50 | "ps": "Pashto", 51 | "fa": "Persian", 52 | "pl": "Polish", 53 | "pt": "Portuguese", 54 | "qu": "Quechua", 55 | "ro": "Romanian", 56 | "ru": "Russian", 57 | "sk": "Slovak", 58 | "es": "Spanish", 59 | "sw": "Swahili", 60 | "sv": "Swedish", 61 | "tl": "Tagalog", 62 | "ta": "Tamil", 63 | "tt": "Tatar", 64 | "te": "Telugu", 65 | "th": "Thai", 66 | "tn": "Tswana", 67 | "tr": "Turkish", 68 | "uk": "Ukrainian", 69 | "ur": "Urdu", 70 | "uz": "Uzbek", 71 | "cy": "Welsh", 72 | } 73 | 74 | var LanguagesWithStopwordsSupport = []string{ 75 | "ar", 76 | "hy", 77 | "eu", 78 | "bn", 79 | "pt-br", 80 | "bg", 81 | "ca", 82 | "zh", 83 | "cs", 84 | "da", 85 | "nl", 86 | "en", 87 | "fi", 88 | "fr", 89 | "gl", 90 | "de", 91 | "el", 92 | "hi", 93 | "id", 94 | "ga", 95 | "it", 96 | "ja", 97 | "ko", 98 | "ku", 99 | "lv", 100 | "lt", 101 | "fa", 102 | "pl", 103 | "pt", 104 | "ro", 105 | "ru", 106 | "sk", 107 | "es", 108 | "sv", 109 | "th", 110 | "tr", 111 | "uk", 112 | "ur", 113 | } 114 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/dictionary/settings/get" 7 | "github.com/algolia/cli/pkg/cmd/dictionary/settings/set" 8 | "github.com/algolia/cli/pkg/cmdutil" 9 | ) 10 | 11 | // NewSettingsCmd returns a new command for dictionaries' entries. 12 | func NewSettingsCmd(f *cmdutil.Factory) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "settings", 15 | Short: "Manage your Algolia dictionary settings", 16 | } 17 | 18 | cmd.AddCommand(set.NewSetCmd(f, nil)) 19 | cmd.AddCommand(get.NewGetCmd(f, nil)) 20 | 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /pkg/cmd/dictionary/shared/constants.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 4 | 5 | // DictionaryTypes returns the allowed dictionary types as strings 6 | func DictionaryTypes() []string { 7 | var types []string 8 | for _, d := range search.AllowedDictionaryTypeEnumValues { 9 | types = append(types, string(d)) 10 | } 11 | return types 12 | } 13 | -------------------------------------------------------------------------------- /pkg/cmd/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/events/tail" 7 | "github.com/algolia/cli/pkg/cmdutil" 8 | ) 9 | 10 | // NewEventsCmd returns a new command for events. 11 | func NewEventsCmd(f *cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "events", 14 | Short: "Manage your Algolia events", 15 | } 16 | 17 | cmd.AddCommand(tail.NewTailCmd(f, nil)) 18 | 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /pkg/cmd/indices/clear/clear_test.go: -------------------------------------------------------------------------------- 1 | package clear 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/google/shlex" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/algolia/cli/pkg/cmdutil" 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/pkg/iostreams" 15 | "github.com/algolia/cli/test" 16 | ) 17 | 18 | func TestNewClearCmd(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | tty bool 22 | cli string 23 | wantsErr bool 24 | wantsOpts ClearOptions 25 | }{ 26 | { 27 | name: "no --confirm without tty", 28 | cli: "foo", 29 | tty: false, 30 | wantsErr: true, 31 | wantsOpts: ClearOptions{ 32 | DoConfirm: true, 33 | Index: "foo", 34 | }, 35 | }, 36 | { 37 | name: "--confirm without tty", 38 | cli: "foo --confirm", 39 | tty: false, 40 | wantsErr: false, 41 | wantsOpts: ClearOptions{ 42 | DoConfirm: false, 43 | Index: "foo", 44 | }, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | io, _, _, _ := iostreams.Test() 51 | if tt.tty { 52 | io.SetStdinTTY(tt.tty) 53 | io.SetStdoutTTY(tt.tty) 54 | } 55 | 56 | f := &cmdutil.Factory{ 57 | IOStreams: io, 58 | } 59 | 60 | var opts *ClearOptions 61 | cmd := NewClearCmd(f, func(o *ClearOptions) error { 62 | opts = o 63 | return nil 64 | }) 65 | 66 | args, err := shlex.Split(tt.cli) 67 | require.NoError(t, err) 68 | cmd.SetArgs(args) 69 | _, err = cmd.ExecuteC() 70 | if tt.wantsErr { 71 | assert.Error(t, err) 72 | return 73 | } else { 74 | require.NoError(t, err) 75 | } 76 | 77 | assert.Equal(t, tt.wantsOpts.Index, opts.Index) 78 | assert.Equal(t, tt.wantsOpts.DoConfirm, opts.DoConfirm) 79 | }) 80 | } 81 | } 82 | 83 | func Test_runCreateCmd(t *testing.T) { 84 | tests := []struct { 85 | name string 86 | cli string 87 | index string 88 | isTTY bool 89 | wantOut string 90 | }{ 91 | { 92 | name: "no TTY", 93 | cli: "foo --confirm", 94 | index: "foo", 95 | isTTY: false, 96 | wantOut: "", 97 | }, 98 | { 99 | name: "TTY", 100 | cli: "foo --confirm", 101 | index: "foo", 102 | isTTY: true, 103 | wantOut: "✓ Cleared index foo\n", 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | r := httpmock.Registry{} 110 | r.Register( 111 | httpmock.REST("POST", fmt.Sprintf("1/indexes/%s/clear", tt.index)), 112 | httpmock.JSONResponse(search.UpdatedAtResponse{}), 113 | ) 114 | defer r.Verify(t) 115 | 116 | f, out := test.NewFactory(tt.isTTY, &r, nil, "") 117 | cmd := NewClearCmd(f, nil) 118 | out, err := test.Execute(cmd, tt.cli, out) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | assert.Equal(t, tt.wantOut, out.String()) 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /pkg/cmd/indices/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | configexport "github.com/algolia/cli/pkg/cmd/indices/config/export" 7 | configimport "github.com/algolia/cli/pkg/cmd/indices/config/import" 8 | "github.com/algolia/cli/pkg/cmdutil" 9 | ) 10 | 11 | // NewConfigCmd returns a new command for index config management 12 | func NewConfigCmd(f *cmdutil.Factory) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "config", 15 | Short: "Manage your Algolia index config (settings, synonyms, rules)", 16 | } 17 | 18 | cmd.AddCommand(configexport.NewExportCmd(f)) 19 | cmd.AddCommand(configimport.NewImportCmd(f)) 20 | 21 | return cmd 22 | } 23 | -------------------------------------------------------------------------------- /pkg/cmd/indices/config/import/confirm.go: -------------------------------------------------------------------------------- 1 | package configimport 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/algolia/cli/pkg/iostreams" 7 | "github.com/algolia/cli/pkg/utils" 8 | ) 9 | 10 | func GetConfirmMessage( 11 | cs *iostreams.ColorScheme, 12 | scope []string, 13 | clearExistingRules, clearExistingSynonyms bool, 14 | ) string { 15 | scopeToClear := []string{} 16 | scopeToUpdate := []string{} 17 | message := "" 18 | 19 | if utils.Contains(scope, "settings") { 20 | scopeToClear = append(scopeToClear, "settings") 21 | } 22 | if utils.Contains(scope, "rules") { 23 | if clearExistingRules { 24 | scopeToClear = append(scopeToClear, "rules") 25 | } else { 26 | scopeToUpdate = append(scopeToUpdate, "rules") 27 | } 28 | } 29 | if utils.Contains(scope, "synonyms") { 30 | if clearExistingSynonyms { 31 | scopeToClear = append(scopeToClear, "synonyms") 32 | } else { 33 | scopeToUpdate = append(scopeToUpdate, "synonyms") 34 | } 35 | } 36 | if len(scopeToClear) > 0 { 37 | message = fmt.Sprintf( 38 | "%s Your %s will be %s\n", 39 | cs.WarningIcon(), 40 | utils.SliceToReadableString(scopeToClear), 41 | cs.Bold("CLEARED and REPLACED."), 42 | ) 43 | } 44 | if len(scopeToUpdate) > 0 { 45 | message = fmt.Sprintf( 46 | "%s%s Your %s will be %s\n", 47 | message, 48 | cs.WarningIcon(), 49 | utils.SliceToReadableString(scopeToUpdate), 50 | cs.Bold("UPDATED"), 51 | ) 52 | } 53 | 54 | return message 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmd/indices/config/import/confirm_test.go: -------------------------------------------------------------------------------- 1 | package configimport 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/algolia/cli/pkg/iostreams" 9 | ) 10 | 11 | func TestGetConfirmMessage(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | 15 | cs *iostreams.ColorScheme 16 | scope []string 17 | clearExistingRules bool 18 | clearExistingSynonyms bool 19 | 20 | wantsOutput string 21 | }{ 22 | { 23 | name: "full scope", 24 | scope: []string{"settings", "rules", "synonyms"}, 25 | clearExistingRules: false, 26 | clearExistingSynonyms: false, 27 | wantsOutput: "! Your settings will be CLEARED and REPLACED.\n! Your rules and synonyms will be UPDATED\n", 28 | }, 29 | { 30 | name: "full scope, --clearExistingSynonyms", 31 | scope: []string{"settings", "rules", "synonyms"}, 32 | clearExistingRules: false, 33 | clearExistingSynonyms: true, 34 | wantsOutput: "! Your settings and synonyms will be CLEARED and REPLACED.\n! Your rules will be UPDATED\n", 35 | }, 36 | { 37 | name: "full scope, --clearExistingRules --clearExistingSynonyms", 38 | scope: []string{"settings", "rules", "synonyms"}, 39 | clearExistingRules: true, 40 | clearExistingSynonyms: true, 41 | wantsOutput: "! Your settings, rules and synonyms will be CLEARED and REPLACED.\n", 42 | }, 43 | { 44 | name: "rules and synonyms scope", 45 | scope: []string{"rules", "synonyms"}, 46 | clearExistingRules: false, 47 | clearExistingSynonyms: false, 48 | wantsOutput: "! Your rules and synonyms will be UPDATED\n", 49 | }, 50 | { 51 | name: "rules and synonyms scope --clearExistingSynonyms", 52 | scope: []string{"rules", "synonyms"}, 53 | clearExistingRules: false, 54 | clearExistingSynonyms: true, 55 | wantsOutput: "! Your synonyms will be CLEARED and REPLACED.\n! Your rules will be UPDATED\n", 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | io, _, _, _ := iostreams.Test() 62 | tt.cs = io.ColorScheme() 63 | 64 | assert.Equal( 65 | t, 66 | tt.wantsOutput, 67 | GetConfirmMessage(tt.cs, tt.scope, tt.clearExistingRules, tt.clearExistingSynonyms), 68 | ) 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/cmd/indices/index.go: -------------------------------------------------------------------------------- 1 | package indices 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/indices/analyze" 7 | "github.com/algolia/cli/pkg/cmd/indices/clear" 8 | "github.com/algolia/cli/pkg/cmd/indices/config" 9 | "github.com/algolia/cli/pkg/cmd/indices/copy" 10 | "github.com/algolia/cli/pkg/cmd/indices/delete" 11 | "github.com/algolia/cli/pkg/cmd/indices/list" 12 | "github.com/algolia/cli/pkg/cmd/indices/move" 13 | "github.com/algolia/cli/pkg/cmdutil" 14 | ) 15 | 16 | // NewIndicesCmd returns a new command for indices. 17 | func NewIndicesCmd(f *cmdutil.Factory) *cobra.Command { 18 | cmd := &cobra.Command{ 19 | Use: "indices", 20 | Aliases: []string{"index"}, 21 | Short: "Manage your Algolia indices", 22 | } 23 | 24 | cmd.AddCommand(list.NewListCmd(f)) 25 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 26 | cmd.AddCommand(clear.NewClearCmd(f, nil)) 27 | cmd.AddCommand(copy.NewCopyCmd(f, nil)) 28 | cmd.AddCommand(move.NewMoveCmd(f, nil)) 29 | cmd.AddCommand(config.NewConfigCmd(f)) 30 | cmd.AddCommand(analyze.NewAnalyzeCmd(f)) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/objects/browse/browse_test.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runBrowseCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | cli string 17 | hits []search.Hit 18 | wantOut string 19 | }{ 20 | { 21 | name: "single object", 22 | cli: "foo", 23 | hits: []search.Hit{{ObjectID: "foo"}}, 24 | wantOut: "{\"objectID\":\"foo\"}\n", 25 | }, 26 | { 27 | name: "multiple objects", 28 | cli: "foo", 29 | hits: []search.Hit{{ObjectID: "foo"}, {ObjectID: "bar"}}, 30 | wantOut: "{\"objectID\":\"foo\"}\n{\"objectID\":\"bar\"}\n", 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | r := httpmock.Registry{} 37 | r.Register( 38 | httpmock.REST("POST", "1/indexes/foo/browse"), 39 | httpmock.JSONResponse(search.BrowseResponse{ 40 | Hits: tt.hits, 41 | }), 42 | ) 43 | defer r.Verify(t) 44 | 45 | f, out := test.NewFactory(true, &r, nil, "") 46 | cmd := NewBrowseCmd(f) 47 | out, err := test.Execute(cmd, tt.cli, out) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | assert.Equal(t, tt.wantOut, out.String()) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmd/objects/import/import_test.go: -------------------------------------------------------------------------------- 1 | package importrecords 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/test" 15 | ) 16 | 17 | func Test_runImportCmd(t *testing.T) { 18 | tmpFile := filepath.Join(t.TempDir(), "objects.json") 19 | err := os.WriteFile(tmpFile, []byte("{\"objectID\":\"foo\"}"), 0o600) 20 | require.NoError(t, err) 21 | 22 | tests := []struct { 23 | name string 24 | cli string 25 | stdin string 26 | wantOut string 27 | wantErr string 28 | }{ 29 | { 30 | name: "from stdin", 31 | cli: "foo -F -", 32 | stdin: `{"objectID": "foo"}`, 33 | wantOut: "✓ Successfully imported 1 objects to foo in", 34 | }, 35 | { 36 | name: "from file", 37 | cli: fmt.Sprintf("foo -F '%s'", tmpFile), 38 | wantOut: "✓ Successfully imported 1 objects to foo in", 39 | }, 40 | { 41 | name: "empty record", 42 | cli: "foo -F -", 43 | stdin: `{}`, 44 | wantErr: "empty object on line 0", 45 | }, 46 | { 47 | name: "missing objectID", 48 | cli: "foo -F -", 49 | stdin: `{"attribute": "foo"}`, 50 | wantErr: "missing objectID on line 0", 51 | }, 52 | { 53 | name: "with auto-generated objectID", 54 | cli: "foo --auto-generate-object-id-if-not-exist -F -", 55 | stdin: `{"attribute": "foo"}`, 56 | wantOut: "✓ Successfully imported 1 objects to foo in", 57 | }, 58 | { 59 | name: "from stdin with invalid JSON", 60 | cli: "foo -F -", 61 | stdin: `{"objectID", "foo"},`, 62 | wantErr: "failed to parse JSON object on line 0: invalid character ',' after object key", 63 | }, 64 | { 65 | name: "missing file flag", 66 | cli: "foo", 67 | wantErr: "required flag(s) \"file\" not set", 68 | }, 69 | { 70 | name: "non-existant file", 71 | cli: "foo -F /tmp/foo", 72 | wantErr: "open /tmp/foo: no such file or directory", 73 | }, 74 | } 75 | 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | r := httpmock.Registry{} 79 | if tt.wantErr == "" { 80 | r.Register( 81 | httpmock.REST("POST", "1/indexes/foo/batch"), 82 | httpmock.JSONResponse(search.BatchResponse{}), 83 | ) 84 | } 85 | defer r.Verify(t) 86 | 87 | f, out := test.NewFactory(true, &r, nil, tt.stdin) 88 | cmd := NewImportCmd(f) 89 | out, err := test.Execute(cmd, tt.cli, out) 90 | if err != nil { 91 | assert.EqualError(t, err, tt.wantErr) 92 | return 93 | } 94 | 95 | assert.Contains(t, out.String(), tt.wantOut) 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/cmd/objects/objects.go: -------------------------------------------------------------------------------- 1 | package objects 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/objects/browse" 7 | "github.com/algolia/cli/pkg/cmd/objects/delete" 8 | importObjects "github.com/algolia/cli/pkg/cmd/objects/import" 9 | "github.com/algolia/cli/pkg/cmd/objects/operations" 10 | updateObjects "github.com/algolia/cli/pkg/cmd/objects/update" 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | ) 13 | 14 | // NewObjectsCmd returns a new command for indices objects. 15 | func NewObjectsCmd(f *cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "objects", 18 | Short: "Add, update, and delete records", 19 | Aliases: []string{"records"}, 20 | } 21 | 22 | cmd.AddCommand(browse.NewBrowseCmd(f)) 23 | cmd.AddCommand(importObjects.NewImportCmd(f)) 24 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 25 | cmd.AddCommand(updateObjects.NewUpdateCmd(f, nil)) 26 | cmd.AddCommand(operations.NewOperationsCmd(f, nil)) 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmd/objects/update/update_test.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/test" 15 | ) 16 | 17 | func Test_runUpdateCmd(t *testing.T) { 18 | tmpFile := filepath.Join(t.TempDir(), "objects.json") 19 | err := os.WriteFile(tmpFile, []byte(`{"objectID":"foo"}`), 0o600) 20 | require.NoError(t, err) 21 | 22 | tests := []struct { 23 | name string 24 | cli string 25 | stdin string 26 | wantOut string 27 | wantErr string 28 | }{ 29 | { 30 | name: "from stdin", 31 | cli: "foo -F -", 32 | stdin: `{"objectID": "foo"}`, 33 | wantOut: "✓ Successfully updated 1 objects on foo in", 34 | }, 35 | { 36 | name: "from file", 37 | cli: fmt.Sprintf("foo -F '%s'", tmpFile), 38 | wantOut: "✓ Successfully updated 1 objects on foo in", 39 | }, 40 | { 41 | name: "from stdin with invalid JSON", 42 | cli: "foo -F -", 43 | stdin: `{"objectID": "foo"},`, 44 | wantErr: "X Found 1 error (out of 1 objects) while parsing the file:\n line 1: invalid character ',' after top-level value\n", 45 | }, 46 | { 47 | name: "from stdin with invalid JSON (multiple objects)", 48 | cli: "foo -F -", 49 | stdin: `{"objectID": "foo"}, 50 | {"test": "bar"}`, 51 | wantErr: "X Found 2 errors (out of 2 objects) while parsing the file:\n line 1: invalid character ',' after top-level value\n line 2: objectID is required\n", 52 | }, 53 | { 54 | name: "from stdin with invalid JSON (1 object) with --continue-on-error", 55 | cli: "foo -F - --continue-on-error", 56 | stdin: `{"objectID": "foo"},`, 57 | wantErr: "X Found 1 error (out of 1 objects) while parsing the file:\n line 1: invalid character ',' after top-level value\n", 58 | }, 59 | { 60 | name: "from stdin with invalid JSON (2 objects) with --continue-on-error", 61 | cli: "foo -F - --continue-on-error", 62 | stdin: `{"objectID": "foo"} 63 | {"test": "bar"}`, 64 | wantOut: "✓ Successfully updated 1 objects on foo in", 65 | }, 66 | { 67 | name: "missing file flag", 68 | cli: "foo", 69 | wantErr: "required flag(s) \"file\" not set", 70 | }, 71 | { 72 | name: "non-existant file", 73 | cli: "foo -F /tmp/foo", 74 | wantErr: "open /tmp/foo: no such file or directory", 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | r := httpmock.Registry{} 81 | if tt.wantErr == "" { 82 | r.Register( 83 | httpmock.REST("POST", "1/indexes/foo/batch"), 84 | httpmock.JSONResponse(search.BatchResponse{}), 85 | ) 86 | } 87 | defer r.Verify(t) 88 | 89 | f, out := test.NewFactory(true, &r, nil, tt.stdin) 90 | cmd := NewUpdateCmd(f, nil) 91 | out, err := test.Execute(cmd, tt.cli, out) 92 | if err != nil { 93 | assert.EqualError(t, err, tt.wantErr) 94 | return 95 | } 96 | 97 | assert.Contains(t, out.String(), tt.wantOut) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/profile/add/add_test.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/shlex" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/test" 14 | ) 15 | 16 | func TestNewAddCmd(t *testing.T) { 17 | cfg := test.NewDefaultConfigStub() 18 | tests := []struct { 19 | name string 20 | tty bool 21 | cli string 22 | cfg config.IConfig 23 | wantsErr bool 24 | wantsOpts AddOptions 25 | }{ 26 | { 27 | name: "not interactive, missing flags", 28 | cli: "", 29 | cfg: cfg, 30 | tty: false, 31 | wantsErr: true, 32 | }, 33 | { 34 | name: "not interactive, all flags", 35 | cli: "--name my-app --app-id my-app-id --api-key my-admin-api-key", 36 | cfg: cfg, 37 | tty: false, 38 | wantsErr: false, 39 | wantsOpts: AddOptions{ 40 | Profile: config.Profile{ 41 | Name: "my-app", 42 | ApplicationID: "my-app-id", 43 | APIKey: "my-admin-api-key", 44 | }, 45 | }, 46 | }, 47 | { 48 | name: "not interactive, all flags, existing profile", 49 | cli: "--name default --app-id my-app-id --api-key my-admin-api-key", 50 | cfg: cfg, 51 | tty: false, 52 | wantsErr: true, 53 | }, 54 | { 55 | name: "not interactive, all flags, existing app ID", 56 | cli: "--name my-app --app-id default --api-key my-admin-api-key", 57 | cfg: cfg, 58 | tty: false, 59 | wantsErr: true, 60 | }, 61 | { 62 | name: "interactive, no flags", 63 | cli: "", 64 | cfg: cfg, 65 | tty: true, 66 | wantsErr: false, 67 | }, 68 | } 69 | 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | io, _, _, _ := iostreams.Test() 73 | io.SetStdinTTY(tt.tty) 74 | io.SetStdoutTTY(tt.tty) 75 | 76 | f := &cmdutil.Factory{ 77 | IOStreams: io, 78 | Config: tt.cfg, 79 | } 80 | 81 | var opts *AddOptions 82 | cmd := NewAddCmd(f, func(o *AddOptions) error { 83 | opts = o 84 | return nil 85 | }) 86 | 87 | args, err := shlex.Split(tt.cli) 88 | require.NoError(t, err) 89 | cmd.SetArgs(args) 90 | _, err = cmd.ExecuteC() 91 | if tt.wantsErr { 92 | assert.Error(t, err) 93 | return 94 | } else { 95 | require.NoError(t, err) 96 | } 97 | 98 | assert.Equal(t, tt.wantsOpts.Profile.Name, opts.Profile.Name) 99 | assert.Equal(t, tt.wantsOpts.Profile.ApplicationID, opts.Profile.ApplicationID) 100 | assert.Equal(t, tt.wantsOpts.Profile.AdminAPIKey, opts.Profile.AdminAPIKey) 101 | assert.Equal(t, tt.wantsOpts.Profile.Default, opts.Profile.Default) 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/cmd/profile/application.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/auth" 7 | "github.com/algolia/cli/pkg/cmd/profile/add" 8 | "github.com/algolia/cli/pkg/cmd/profile/list" 9 | "github.com/algolia/cli/pkg/cmd/profile/remove" 10 | "github.com/algolia/cli/pkg/cmd/profile/setdefault" 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | ) 13 | 14 | // NewProfileCmd returns a new command for managing profiles. 15 | func NewProfileCmd(f *cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "profile", 18 | Aliases: []string{"profiles"}, 19 | Short: "Manage your Algolia CLI profiles", 20 | } 21 | 22 | auth.DisableAuthCheck(cmd) 23 | 24 | cmd.AddCommand(add.NewAddCmd(f, nil)) 25 | cmd.AddCommand(list.NewListCmd(f, nil)) 26 | cmd.AddCommand(remove.NewRemoveCmd(f, nil)) 27 | cmd.AddCommand(setdefault.NewSetDefaultCmd(f, nil)) 28 | 29 | return cmd 30 | } 31 | -------------------------------------------------------------------------------- /pkg/cmd/profile/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/printers" 14 | "github.com/algolia/cli/pkg/validators" 15 | ) 16 | 17 | // ListOptions represents the options for the list command 18 | type ListOptions struct { 19 | config config.IConfig 20 | IO *iostreams.IOStreams 21 | } 22 | 23 | // NewListCmd returns a new instance of ListCmd 24 | func NewListCmd(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { 25 | opts := &ListOptions{ 26 | IO: f.IOStreams, 27 | config: f.Config, 28 | } 29 | cmd := &cobra.Command{ 30 | Use: "list", 31 | Aliases: []string{"l"}, 32 | Args: validators.NoArgs(), 33 | Short: "List the configured profile(s)", 34 | Example: heredoc.Doc(` 35 | # List the configured profiles 36 | $ algolia profile list 37 | `), 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | if runF != nil { 40 | return runF(opts) 41 | } 42 | 43 | return runListCmd(opts) 44 | }, 45 | } 46 | 47 | return cmd 48 | } 49 | 50 | // runListCmd executes the list command 51 | func runListCmd(opts *ListOptions) error { 52 | profiles := opts.config.ConfiguredProfiles() 53 | if len(profiles) == 0 { 54 | fmt.Fprintln(opts.IO.ErrOut, "No configured profiles") 55 | fmt.Fprintln(opts.IO.ErrOut, "Use `algolia profile add` to add a profile") 56 | return nil 57 | } 58 | 59 | table := printers.NewTablePrinter(opts.IO) 60 | if table.IsTTY() { 61 | table.AddField("NAME", nil, nil) 62 | table.AddField("APPLICATION ID", nil, nil) 63 | table.AddField("NUMBER OF INDICES", nil, nil) 64 | table.AddField("DEFAULT", nil, nil) 65 | table.EndRow() 66 | } 67 | 68 | cs := opts.IO.ColorScheme() 69 | 70 | opts.IO.StartProgressIndicatorWithLabel("Fetching configured profiles") 71 | for _, profile := range profiles { 72 | table.AddField(profile.Name, nil, nil) 73 | table.AddField(profile.ApplicationID, nil, nil) 74 | 75 | apiKey := profile.APIKey 76 | if apiKey == "" { 77 | apiKey = profile.AdminAPIKey // Legacy 78 | } 79 | 80 | client, err := search.NewClient(profile.ApplicationID, apiKey) 81 | if err != nil { 82 | table.AddField(err.Error(), nil, nil) 83 | } 84 | res, err := client.ListIndices(client.NewApiListIndicesRequest()) 85 | if err != nil { 86 | table.AddField(err.Error(), nil, nil) 87 | } else { 88 | table.AddField(fmt.Sprintf("%d", len(res.Items)), nil, nil) 89 | } 90 | 91 | if profile.Default { 92 | table.AddField(cs.SuccessIcon(), nil, nil) 93 | } else { 94 | table.AddField("", nil, nil) 95 | } 96 | table.EndRow() 97 | } 98 | opts.IO.StopProgressIndicator() 99 | return table.Render() 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/profile/remove/remove.go: -------------------------------------------------------------------------------- 1 | package remove 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | "github.com/algolia/cli/pkg/config" 11 | "github.com/algolia/cli/pkg/iostreams" 12 | "github.com/algolia/cli/pkg/prompt" 13 | "github.com/algolia/cli/pkg/validators" 14 | ) 15 | 16 | // RemoveOptions represents the options for the remove command 17 | type RemoveOptions struct { 18 | config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | Profile string 22 | DefaultProfile string 23 | 24 | DoConfirm bool 25 | } 26 | 27 | // NewRemoveCmd returns a new instance of RemoveCmd 28 | func NewRemoveCmd(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command { 29 | opts := &RemoveOptions{ 30 | IO: f.IOStreams, 31 | config: f.Config, 32 | } 33 | 34 | var confirm bool 35 | 36 | cmd := &cobra.Command{ 37 | Use: "remove ", 38 | Args: validators.ExactArgs(1), 39 | ValidArgsFunction: cmdutil.ConfiguredProfilesCompletionFunc(f), 40 | Short: "Remove the specified profile", 41 | Long: `Remove the specified profile from the configuration.`, 42 | Example: heredoc.Doc(` 43 | # Remove the profile named "my-app" from the configuration 44 | $ algolia profile remove my-app 45 | `), 46 | RunE: func(cmd *cobra.Command, args []string) error { 47 | opts.Profile = args[0] 48 | 49 | if opts.config.Default() != nil { 50 | opts.DefaultProfile = opts.config.Default().Name 51 | } else { 52 | opts.DefaultProfile = "" 53 | } 54 | 55 | if !confirm && opts.Profile == opts.DefaultProfile { 56 | if !opts.IO.CanPrompt() { 57 | return cmdutil.FlagErrorf( 58 | "--confirm required when non-interactive shell is detected", 59 | ) 60 | } 61 | opts.DoConfirm = true 62 | } 63 | 64 | if !opts.config.ProfileExists(opts.Profile) { 65 | return fmt.Errorf("the specified profile does not exist: '%s'", opts.Profile) 66 | } 67 | 68 | if runF != nil { 69 | return runF(opts) 70 | } 71 | 72 | return runRemoveCmd(opts) 73 | }, 74 | } 75 | 76 | cmd.Flags(). 77 | BoolVarP(&confirm, "confirm", "y", false, "Skip the remove profile confirmation prompt") 78 | 79 | return cmd 80 | } 81 | 82 | // runRemoveCmd executes the remove command 83 | func runRemoveCmd(opts *RemoveOptions) error { 84 | if opts.DoConfirm { 85 | var confirmed bool 86 | err := prompt.Confirm( 87 | fmt.Sprintf("Are you sure you want to remove '%s', the default profile?", opts.Profile), 88 | &confirmed, 89 | ) 90 | if err != nil { 91 | return err 92 | } 93 | if !confirmed { 94 | return nil 95 | } 96 | } 97 | 98 | err := opts.config.RemoveProfile(opts.Profile) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | cs := opts.IO.ColorScheme() 104 | if opts.IO.IsStdoutTTY() { 105 | extra := "." 106 | if opts.DefaultProfile == opts.Profile { 107 | extra = ". Set a new default profile with 'algolia profile setdefault'." 108 | } 109 | if len(opts.config.ConfiguredProfiles()) == 0 { 110 | extra = ". Add a profile with 'algolia profile add'." 111 | } 112 | if _, err = fmt.Fprintf(opts.IO.Out, "%s '%s' removed successfully%s\n", cs.SuccessIcon(), opts.Profile, extra); err != nil { 113 | return err 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/cmd/profile/setdefault/setdefault.go: -------------------------------------------------------------------------------- 1 | package setdefault 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | "github.com/algolia/cli/pkg/config" 11 | "github.com/algolia/cli/pkg/iostreams" 12 | "github.com/algolia/cli/pkg/validators" 13 | ) 14 | 15 | // SetDefaultOptions represents the options for the setdefault command 16 | type SetDefaultOptions struct { 17 | config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | Profile string 21 | } 22 | 23 | // NewSetDefaultCmd returns a new instance of SetDefaultCmd 24 | func NewSetDefaultCmd(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *cobra.Command { 25 | opts := &SetDefaultOptions{ 26 | IO: f.IOStreams, 27 | config: f.Config, 28 | } 29 | cmd := &cobra.Command{ 30 | Use: "setdefault ", 31 | Args: validators.ExactArgs(1), 32 | ValidArgsFunction: cmdutil.ConfiguredProfilesCompletionFunc(f), 33 | Short: "Set the default profile", 34 | Example: heredoc.Doc(` 35 | # Set the default profile to "my-app" 36 | $ algolia profile setdefault my-app 37 | `), 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | opts.Profile = args[0] 40 | 41 | if !opts.config.ProfileExists(opts.Profile) { 42 | return fmt.Errorf("the specified profile does not exist: '%s'", opts.Profile) 43 | } 44 | 45 | if runF != nil { 46 | return runF(opts) 47 | } 48 | 49 | return runSetDefaultCmd(opts) 50 | }, 51 | } 52 | 53 | return cmd 54 | } 55 | 56 | // runSetDefaultCmd executes the setdefault command 57 | func runSetDefaultCmd(opts *SetDefaultOptions) error { 58 | var defaultName string 59 | for _, profile := range opts.config.ConfiguredProfiles() { 60 | if profile.Default { 61 | defaultName = profile.Name 62 | } 63 | } 64 | 65 | err := opts.config.SetDefaultProfile(opts.Profile) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | cs := opts.IO.ColorScheme() 71 | 72 | opts.config.Profile().LoadDefault() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | if opts.IO.IsStdoutTTY() { 78 | if defaultName != "" { 79 | if _, err = fmt.Fprintf(opts.IO.Out, "%s Default profile successfuly changed from '%s' to '%s'.\n", cs.SuccessIcon(), defaultName, opts.Profile); err != nil { 80 | return err 81 | } 82 | } else { 83 | if _, err = fmt.Fprintf(opts.IO.Out, "%s Default profile successfuly set to '%s'.\n", cs.SuccessIcon(), opts.Profile); err != nil { 84 | return err 85 | } 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/cmd/profile/setdefault/setdefault_test.go: -------------------------------------------------------------------------------- 1 | package setdefault 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/cli/pkg/config" 7 | "github.com/algolia/cli/test" 8 | "github.com/bmizerany/assert" 9 | ) 10 | 11 | func Test_runSetDefaultCmd(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | cli string 15 | profiles map[string]bool 16 | wantsErr string 17 | wantOut string 18 | }{ 19 | { 20 | name: "existing default", 21 | cli: "foo", 22 | profiles: map[string]bool{"default": true, "foo": false}, 23 | wantOut: "✓ Default profile successfuly changed from 'default' to 'foo'.\n", 24 | }, 25 | { 26 | name: "non-existing default", 27 | cli: "foo", 28 | profiles: map[string]bool{"foo": false}, 29 | wantOut: "✓ Default profile successfuly set to 'foo'.\n", 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | var p []*config.Profile 36 | for k, v := range tt.profiles { 37 | p = append(p, &config.Profile{ 38 | Name: k, 39 | Default: v, 40 | }) 41 | } 42 | cfg := test.NewConfigStubWithProfiles(p) 43 | f, out := test.NewFactory(true, nil, cfg, "") 44 | cmd := NewSetDefaultCmd(f, nil) 45 | out, err := test.Execute(cmd, tt.cli, out) 46 | if err != nil { 47 | assert.Equal(t, tt.wantsErr, err.Error()) 48 | return 49 | } 50 | 51 | assert.Equal(t, tt.wantOut, out.String()) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/root/root_test.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "testing" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/algolia/cli/pkg/cmdutil" 13 | ) 14 | 15 | func TestPrintError(t *testing.T) { 16 | cmd := &cobra.Command{} 17 | 18 | type args struct { 19 | err error 20 | cmd *cobra.Command 21 | debug bool 22 | } 23 | tests := []struct { 24 | name string 25 | args args 26 | wantOut string 27 | }{ 28 | { 29 | name: "generic error", 30 | args: args{ 31 | err: errors.New("the app exploded"), 32 | cmd: nil, 33 | debug: false, 34 | }, 35 | wantOut: "the app exploded\n", 36 | }, 37 | { 38 | name: "DNS error", 39 | args: args{ 40 | err: fmt.Errorf("DNS oopsie: %w", &net.DNSError{ 41 | Name: "latency.algolia.net", 42 | }), 43 | cmd: nil, 44 | debug: false, 45 | }, 46 | wantOut: `error connecting to latency.algolia.net 47 | check your internet connection or https://status.algolia.com 48 | `, 49 | }, 50 | { 51 | name: "Cobra flag error", 52 | args: args{ 53 | err: &cmdutil.FlagError{Err: errors.New("unknown flag --foo")}, 54 | cmd: cmd, 55 | debug: false, 56 | }, 57 | wantOut: "unknown flag --foo\n\nUsage:\n\n", 58 | }, 59 | { 60 | name: "unknown Cobra command error", 61 | args: args{ 62 | err: errors.New("unknown command foo"), 63 | cmd: cmd, 64 | debug: false, 65 | }, 66 | wantOut: "unknown command foo\n\nUsage:\n\n", 67 | }, 68 | } 69 | 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | out := &bytes.Buffer{} 73 | printError(out, tt.args.err, tt.args.cmd, tt.args.debug) 74 | if gotOut := out.String(); gotOut != tt.wantOut { 75 | t.Errorf("printError() = %q, want %q", gotOut, tt.wantOut) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/cmd/rules/browse/browse.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/MakeNowJust/heredoc" 10 | 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | "github.com/algolia/cli/pkg/config" 13 | "github.com/algolia/cli/pkg/iostreams" 14 | "github.com/algolia/cli/pkg/validators" 15 | ) 16 | 17 | type ExportOptions struct { 18 | Config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | SearchClient func() (*search.APIClient, error) 22 | 23 | Index string 24 | 25 | PrintFlags *cmdutil.PrintFlags 26 | } 27 | 28 | // NewBrowseCmd creates and returns a browse command for Rules 29 | func NewBrowseCmd(f *cmdutil.Factory) *cobra.Command { 30 | opts := &ExportOptions{ 31 | IO: f.IOStreams, 32 | Config: f.Config, 33 | SearchClient: f.SearchClient, 34 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 35 | } 36 | 37 | cmd := &cobra.Command{ 38 | Use: "browse ", 39 | Args: validators.ExactArgs(1), 40 | Aliases: []string{"list", "l"}, 41 | ValidArgsFunction: cmdutil.IndexNames(opts.SearchClient), 42 | Short: "List an indices' rules.", 43 | Annotations: map[string]string{ 44 | "runInWebCLI": "true", 45 | "acls": "settings", 46 | }, 47 | Example: heredoc.Doc(` 48 | # List all the rules of the "MOVIES" index 49 | $ algolia rules browse MOVIES 50 | 51 | # List all the rules of the "MOVIES" index and save them to a 'rules.ndjson' file 52 | $ algolia rules browse MOVIES -o json > rules.ndjson 53 | `), 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | opts.Index = args[0] 56 | 57 | return runListCmd(opts) 58 | }, 59 | } 60 | 61 | opts.PrintFlags.AddFlags(cmd) 62 | 63 | return cmd 64 | } 65 | 66 | func runListCmd(opts *ExportOptions) error { 67 | client, err := opts.SearchClient() 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Check if index exists because the API just returns an empty list if it doesn't 73 | exists, err := client.IndexExists(opts.Index) 74 | if err != nil { 75 | return err 76 | } 77 | if !exists { 78 | return fmt.Errorf("index %s doesn't exist", opts.Index) 79 | } 80 | 81 | p, err := opts.PrintFlags.ToPrinter() 82 | if err != nil { 83 | return err 84 | } 85 | err = client.BrowseRules( 86 | opts.Index, 87 | *search.NewEmptySearchRulesParams(), 88 | search.WithAggregator(func(res any, _ error) { 89 | for _, rule := range res.(*search.SearchRulesResponse).Hits { 90 | if err = p.Print(opts.IO, rule); err != nil { 91 | continue 92 | } 93 | } 94 | }), 95 | ) 96 | if err != nil { 97 | return err 98 | } 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/rules/browse/browse_test.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runBrowseCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | cli string 17 | hits []search.Rule 18 | wantOut string 19 | }{ 20 | { 21 | name: "single rule", 22 | cli: "foo", 23 | hits: []search.Rule{{ObjectID: "foo"}}, 24 | wantOut: "{\"consequence\":{},\"objectID\":\"foo\"}\n", 25 | }, 26 | { 27 | name: "multiple rules", 28 | cli: "foo", 29 | hits: []search.Rule{{ObjectID: "foo"}, {ObjectID: "bar"}}, 30 | wantOut: "{\"consequence\":{},\"objectID\":\"foo\"}\n{\"consequence\":{},\"objectID\":\"bar\"}\n", 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | r := httpmock.Registry{} 37 | r.Register( 38 | httpmock.REST("GET", "1/indexes/foo/settings"), 39 | httpmock.JSONResponse(search.SettingsResponse{}), 40 | ) 41 | r.Register( 42 | httpmock.REST("POST", "1/indexes/foo/rules/search"), 43 | httpmock.JSONResponse(search.SearchRulesResponse{ 44 | Hits: tt.hits, 45 | }), 46 | ) 47 | defer r.Verify(t) 48 | 49 | f, out := test.NewFactory(true, &r, nil, "") 50 | cmd := NewBrowseCmd(f) 51 | out, err := test.Execute(cmd, tt.cli, out) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | assert.Equal(t, tt.wantOut, out.String()) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/rules/rules.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/rules/browse" 7 | "github.com/algolia/cli/pkg/cmd/rules/delete" 8 | importRules "github.com/algolia/cli/pkg/cmd/rules/import" 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | ) 11 | 12 | // NewRulesCmd returns a new command for rules. 13 | func NewRulesCmd(f *cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "rules", 16 | Aliases: []string{"rule"}, 17 | Short: "Manage your Algolia rules", 18 | } 19 | 20 | cmd.AddCommand(importRules.NewImportCmd(f, nil)) 21 | cmd.AddCommand(browse.NewBrowseCmd(f)) 22 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 23 | 24 | return cmd 25 | } 26 | -------------------------------------------------------------------------------- /pkg/cmd/settings/get/list.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/validators" 14 | ) 15 | 16 | type GetOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | SearchClient func() (*search.APIClient, error) 21 | 22 | Index string 23 | 24 | PrintFlags *cmdutil.PrintFlags 25 | } 26 | 27 | // NewGetCmd creates and returns a get command for settings 28 | func NewGetCmd(f *cmdutil.Factory) *cobra.Command { 29 | opts := &GetOptions{ 30 | IO: f.IOStreams, 31 | Config: f.Config, 32 | SearchClient: f.SearchClient, 33 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 34 | } 35 | cmd := &cobra.Command{ 36 | Use: "get ", 37 | Args: validators.ExactArgs(1), 38 | Short: "Get the settings of the specified index.", 39 | Annotations: map[string]string{ 40 | "runInWebCLI": "true", 41 | "acls": "settings", 42 | }, 43 | Example: heredoc.Doc(` 44 | # Store the settings of an index in a file 45 | $ algolia settings get MOVIES > movies_settings.json 46 | `), 47 | ValidArgsFunction: cmdutil.IndexNames(opts.SearchClient), 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | opts.Index = args[0] 50 | 51 | return runListCmd(opts) 52 | }, 53 | } 54 | 55 | opts.PrintFlags.AddFlags(cmd) 56 | 57 | return cmd 58 | } 59 | 60 | func runListCmd(opts *GetOptions) error { 61 | client, err := opts.SearchClient() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | p, err := opts.PrintFlags.ToPrinter() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | opts.IO.StartProgressIndicatorWithLabel(fmt.Sprint("Fetching settings for index ", opts.Index)) 72 | res, err := client.GetSettings(client.NewApiGetSettingsRequest(opts.Index)) 73 | opts.IO.StopProgressIndicator() 74 | if err != nil { 75 | return err 76 | } 77 | 78 | return p.Print(opts.IO, res) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/cmd/settings/import/import.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | "github.com/algolia/cli/pkg/config" 13 | "github.com/algolia/cli/pkg/iostreams" 14 | "github.com/algolia/cli/pkg/validators" 15 | ) 16 | 17 | type ImportOptions struct { 18 | Config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | SearchClient func() (*search.APIClient, error) 22 | 23 | Index string 24 | Settings search.IndexSettings 25 | ForwardToReplicas bool 26 | Wait bool 27 | } 28 | 29 | // NewImportCmd creates and returns an import command for settings 30 | func NewImportCmd(f *cmdutil.Factory) *cobra.Command { 31 | opts := &ImportOptions{ 32 | IO: f.IOStreams, 33 | Config: f.Config, 34 | SearchClient: f.SearchClient, 35 | } 36 | 37 | var settingsFile string 38 | 39 | cmd := &cobra.Command{ 40 | Use: "import -F ", 41 | Args: validators.ExactArgs(1), 42 | ValidArgsFunction: cmdutil.IndexNames(opts.SearchClient), 43 | Annotations: map[string]string{ 44 | "acls": "editSettings", 45 | }, 46 | Short: "Import index settings from a file.", 47 | Example: heredoc.Doc(` 48 | # Import the settings from "settings.json" to the "MOVIES" index 49 | $ algolia settings import MOVIES -F settings.json 50 | `), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | opts.Index = args[0] 53 | b, err := cmdutil.ReadFile(settingsFile, opts.IO.In) 54 | if err != nil { 55 | return err 56 | } 57 | err = json.Unmarshal(b, &opts.Settings) 58 | if err != nil { 59 | return err 60 | } 61 | return runImportCmd(opts) 62 | }, 63 | } 64 | cmd.Flags(). 65 | StringVarP(&settingsFile, "file", "F", "", "Import settings from a `file` (use \"-\" to read from standard input)") 66 | _ = cmd.MarkFlagRequired("file") 67 | cmd.Flags(). 68 | BoolVarP(&opts.ForwardToReplicas, "forward-to-replicas", "f", false, "Forward the settings to the replicas") 69 | cmd.Flags().BoolVarP(&opts.Wait, "wait", "w", false, "wait for the operation to complete") 70 | 71 | return cmd 72 | } 73 | 74 | func runImportCmd(opts *ImportOptions) error { 75 | client, err := opts.SearchClient() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | opts.IO.StartProgressIndicatorWithLabel(fmt.Sprint("Importing settings to index ", opts.Index)) 81 | res, err := client.SetSettings( 82 | client.NewApiSetSettingsRequest(opts.Index, &opts.Settings). 83 | WithForwardToReplicas(opts.ForwardToReplicas), 84 | ) 85 | if err != nil { 86 | opts.IO.StopProgressIndicator() 87 | return err 88 | } 89 | 90 | if opts.Wait { 91 | opts.IO.UpdateProgressIndicatorLabel("Waiting for the task to complete") 92 | _, err := client.WaitForTask(opts.Index, res.TaskID) 93 | if err != nil { 94 | opts.IO.StopProgressIndicator() 95 | return err 96 | } 97 | } 98 | 99 | opts.IO.StopProgressIndicator() 100 | 101 | cs := opts.IO.ColorScheme() 102 | if opts.IO.IsStdoutTTY() { 103 | fmt.Fprintf(opts.IO.Out, "%s Imported settings on %v\n", cs.SuccessIcon(), opts.Index) 104 | } 105 | 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /pkg/cmd/settings/import/import_test.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/algolia/cli/pkg/httpmock" 14 | "github.com/algolia/cli/test" 15 | ) 16 | 17 | func Test_runExportCmd(t *testing.T) { 18 | tmpFile := filepath.Join(t.TempDir(), "settings.json") 19 | err := os.WriteFile(tmpFile, []byte("{\"enableReRanking\":false}"), 0o600) 20 | require.NoError(t, err) 21 | 22 | tests := []struct { 23 | name string 24 | cli string 25 | stdin string 26 | wantOut string 27 | }{ 28 | { 29 | name: "from stdin", 30 | cli: "foo -F -", 31 | stdin: `{"enableReRanking": true}`, 32 | wantOut: "✓ Imported settings on foo\n", 33 | }, 34 | { 35 | name: "from file", 36 | cli: fmt.Sprintf("foo -F '%s'", tmpFile), 37 | wantOut: "✓ Imported settings on foo\n", 38 | }, 39 | } 40 | 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | r := httpmock.Registry{} 44 | r.Register( 45 | httpmock.REST("PUT", "1/indexes/foo/settings"), 46 | httpmock.JSONResponse(search.UpdatedAtResponse{}), 47 | ) 48 | defer r.Verify(t) 49 | 50 | f, out := test.NewFactory(true, &r, nil, tt.stdin) 51 | cmd := NewImportCmd(f) 52 | out, err := test.Execute(cmd, tt.cli, out) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | assert.Equal(t, tt.wantOut, out.String()) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/cmd/settings/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/algolia/cli/pkg/cmdutil" 12 | "github.com/algolia/cli/pkg/config" 13 | "github.com/algolia/cli/pkg/iostreams" 14 | "github.com/algolia/cli/pkg/validators" 15 | ) 16 | 17 | type SetOptions struct { 18 | Config config.IConfig 19 | IO *iostreams.IOStreams 20 | 21 | SearchClient func() (*search.APIClient, error) 22 | 23 | Settings search.IndexSettings 24 | ForwardToReplicas bool 25 | Wait bool 26 | 27 | Index string 28 | } 29 | 30 | // NewSetCmd creates and returns a set command for settings 31 | func NewSetCmd(f *cmdutil.Factory) *cobra.Command { 32 | opts := &SetOptions{ 33 | IO: f.IOStreams, 34 | Config: f.Config, 35 | SearchClient: f.SearchClient, 36 | } 37 | cmd := &cobra.Command{ 38 | Use: "set ", 39 | Args: validators.ExactArgs(1), 40 | Annotations: map[string]string{ 41 | "acls": "editSettings", 42 | }, 43 | Short: "Specify index settings.", 44 | Example: heredoc.Doc(` 45 | # Set the typo tolerance to false on the MOVIES index 46 | $ algolia settings set MOVIES --typoTolerance="false" 47 | `), 48 | ValidArgsFunction: cmdutil.IndexNames(opts.SearchClient), 49 | RunE: func(cmd *cobra.Command, args []string) error { 50 | opts.Index = args[0] 51 | 52 | settings, err := cmdutil.FlagValuesMap(cmd.Flags(), cmdutil.IndexSettings...) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // Serialize / Deseralize the settings 58 | tmp, err := json.Marshal(settings) 59 | if err != nil { 60 | return err 61 | } 62 | err = json.Unmarshal(tmp, &opts.Settings) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return runSetCmd(opts) 68 | }, 69 | } 70 | 71 | cmd.Flags(). 72 | BoolVarP(&opts.ForwardToReplicas, "forward-to-replicas", "f", false, "Whether to apply settings changes also to replicas") 73 | cmd.Flags().BoolVarP(&opts.Wait, "wait", "w", false, "Wait for the operation to complete") 74 | 75 | cmdutil.AddIndexSettingsFlags(cmd) 76 | 77 | return cmd 78 | } 79 | 80 | func runSetCmd(opts *SetOptions) error { 81 | client, err := opts.SearchClient() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | opts.IO.StartProgressIndicatorWithLabel( 87 | fmt.Sprintf("Setting settings for index %s", opts.Index), 88 | ) 89 | 90 | res, err := client.SetSettings( 91 | client.NewApiSetSettingsRequest(opts.Index, &opts.Settings). 92 | WithForwardToReplicas(opts.ForwardToReplicas), 93 | ) 94 | if err != nil { 95 | opts.IO.StopProgressIndicator() 96 | return err 97 | } 98 | 99 | if opts.Wait { 100 | opts.IO.UpdateProgressIndicatorLabel("Waiting for the task to complete") 101 | _, err := client.WaitForTask(opts.Index, res.TaskID) 102 | if err != nil { 103 | opts.IO.StopProgressIndicator() 104 | return err 105 | } 106 | } 107 | 108 | opts.IO.StopProgressIndicator() 109 | 110 | cs := opts.IO.ColorScheme() 111 | if opts.IO.IsStdoutTTY() { 112 | fmt.Fprintf(opts.IO.Out, "%s Set settings on %v\n", cs.SuccessIcon(), opts.Index) 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /pkg/cmd/settings/set/set_test.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runSetCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | cli string 17 | wantOut string 18 | }{ 19 | { 20 | name: "without forwardToReplicas", 21 | cli: "foo --advancedSyntax", 22 | wantOut: "✓ Set settings on foo\n", 23 | }, 24 | { 25 | name: "with forwardToReplicas", 26 | cli: "foo --advancedSyntax --forward-to-replicas", 27 | wantOut: "✓ Set settings on foo\n", 28 | }, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | r := httpmock.Registry{} 34 | r.Register( 35 | httpmock.REST("PUT", "1/indexes/foo/settings"), 36 | httpmock.JSONResponse(search.UpdatedAtResponse{}), 37 | ) 38 | defer r.Verify(t) 39 | 40 | f, out := test.NewFactory(true, &r, nil, "") 41 | cmd := NewSetCmd(f) 42 | out, err := test.Execute(cmd, tt.cli, out) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | assert.Equal(t, tt.wantOut, out.String()) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/cmd/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/settings/get" 7 | importSettings "github.com/algolia/cli/pkg/cmd/settings/import" 8 | "github.com/algolia/cli/pkg/cmd/settings/set" 9 | "github.com/algolia/cli/pkg/cmdutil" 10 | ) 11 | 12 | // NewSettingsCmd returns a new command for managing settings. 13 | func NewSettingsCmd(f *cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "settings", 16 | Short: "Manage your Algolia index settings.", 17 | } 18 | 19 | cmd.AddCommand(get.NewGetCmd(f)) 20 | cmd.AddCommand(set.NewSetCmd(f)) 21 | cmd.AddCommand(importSettings.NewImportCmd(f)) 22 | 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /pkg/cmd/shared/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/algolia/cli/pkg/iostreams" 8 | "github.com/algolia/cli/pkg/utils" 9 | ) 10 | 11 | func GetSynonyms(client *search.APIClient, srcIndex string) ([]search.SynonymHit, error) { 12 | var synonyms []search.SynonymHit 13 | 14 | err := client.BrowseSynonyms( 15 | srcIndex, 16 | *search.NewEmptySearchSynonymsParams(), 17 | search.WithAggregator(func(res any, _ error) { 18 | response, _ := res.(search.SearchSynonymsResponse) 19 | synonyms = append(synonyms, response.Hits...) 20 | }), 21 | ) 22 | if err != nil { 23 | return nil, fmt.Errorf("cannot retrieve synonyms from source index: %s: %v", srcIndex, err) 24 | } 25 | return synonyms, nil 26 | } 27 | 28 | func GetRules(client *search.APIClient, srcIndex string) ([]search.Rule, error) { 29 | var rules []search.Rule 30 | 31 | err := client.BrowseRules( 32 | srcIndex, 33 | *search.NewEmptySearchRulesParams(), 34 | search.WithAggregator(func(res any, _ error) { 35 | response, _ := res.(search.SearchRulesResponse) 36 | rules = append(rules, response.Hits...) 37 | }), 38 | ) 39 | if err != nil { 40 | return nil, fmt.Errorf("cannot retrieve rules from source index: %s: %v", srcIndex, err) 41 | } 42 | return rules, nil 43 | } 44 | 45 | type ExportConfigJSON struct { 46 | Settings *search.SettingsResponse `json:"settings,omitempty"` 47 | Rules []search.Rule `json:"rules,omitempty"` 48 | Synonyms []search.SynonymHit `json:"synonyms,omitempty"` 49 | } 50 | 51 | func GetIndexConfig( 52 | client *search.APIClient, 53 | index string, 54 | scope []string, 55 | cs *iostreams.ColorScheme, 56 | ) (*ExportConfigJSON, error) { 57 | var configJSON ExportConfigJSON 58 | 59 | if utils.Contains(scope, "synonyms") { 60 | rawSynonyms, err := GetSynonyms(client, index) 61 | if err != nil { 62 | return nil, fmt.Errorf( 63 | "%s An error occurred when retrieving synonyms: %w", 64 | cs.FailureIcon(), 65 | err, 66 | ) 67 | } 68 | configJSON.Synonyms = rawSynonyms 69 | } 70 | 71 | if utils.Contains(scope, "rules") { 72 | rawRules, err := GetRules(client, index) 73 | if err != nil { 74 | return nil, fmt.Errorf( 75 | "%s An error occurred when retrieving rules: %w", 76 | cs.FailureIcon(), 77 | err, 78 | ) 79 | } 80 | configJSON.Rules = rawRules 81 | } 82 | 83 | if utils.Contains(scope, "settings") { 84 | rawSettings, err := client.GetSettings(client.NewApiGetSettingsRequest(index)) 85 | if err != nil { 86 | return nil, fmt.Errorf( 87 | "%s An error occurred when retrieving settings: %w", 88 | cs.FailureIcon(), 89 | err, 90 | ) 91 | } 92 | configJSON.Settings = rawSettings 93 | } 94 | 95 | if len(configJSON.Rules) == 0 && len(configJSON.Synonyms) == 0 && configJSON.Settings == nil { 96 | return nil, fmt.Errorf("%s No config to export", cs.FailureIcon()) 97 | } 98 | 99 | return &configJSON, nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/shared/handler/flags_handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | config "github.com/algolia/cli/pkg/cmd/shared/handler/indices" 5 | synonyms "github.com/algolia/cli/pkg/cmd/shared/handler/synonyms" 6 | "github.com/algolia/cli/pkg/cmd/synonyms/shared" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type FlagsHandler interface { 11 | Validate() error 12 | AskAndFill() error 13 | } 14 | 15 | func HandleFlags(handler FlagsHandler, interactive bool) error { 16 | err := handler.Validate() 17 | if interactive && err != nil { 18 | return handler.AskAndFill() 19 | } 20 | 21 | return err 22 | } 23 | 24 | // `synonyms save` 25 | type SynonymHandler struct { 26 | Flags *shared.SynonymFlags 27 | Cmd *cobra.Command 28 | } 29 | 30 | func (handler SynonymHandler) Validate() error { 31 | return synonyms.ValidateSynonymFlags(*handler.Flags) 32 | } 33 | 34 | func (handler *SynonymHandler) AskAndFill() error { 35 | err := synonyms.AskSynonym(handler.Flags, handler.Cmd) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | return synonyms.ValidateSynonymFlags(*handler.Flags) 41 | } 42 | 43 | // `indices config export` 44 | type IndexConfigExportHandler struct { 45 | Opts *config.ExportOptions 46 | } 47 | 48 | func (handler IndexConfigExportHandler) Validate() error { 49 | return config.ValidateExportConfigFlags(*handler.Opts) 50 | } 51 | 52 | func (handler *IndexConfigExportHandler) AskAndFill() error { 53 | err := config.AskExportConfig(handler.Opts) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | return config.ValidateExportConfigFlags(*handler.Opts) 59 | } 60 | 61 | // `indices config import` 62 | type IndexConfigImportHandler struct { 63 | Opts *config.ImportOptions 64 | } 65 | 66 | func (handler IndexConfigImportHandler) Validate() error { 67 | return config.ValidateImportConfigFlags(handler.Opts) 68 | } 69 | 70 | func (handler *IndexConfigImportHandler) AskAndFill() error { 71 | err := config.AskImportConfig(handler.Opts) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | return config.ValidateImportConfigFlags(handler.Opts) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/cmd/synonyms/browse/browse.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MakeNowJust/heredoc" 7 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | "github.com/algolia/cli/pkg/validators" 14 | ) 15 | 16 | type BrowseOptions struct { 17 | Config config.IConfig 18 | IO *iostreams.IOStreams 19 | 20 | SearchClient func() (*search.APIClient, error) 21 | 22 | Index string 23 | 24 | PrintFlags *cmdutil.PrintFlags 25 | } 26 | 27 | // NewBrowseCmd creates and returns a browse command for synonyms 28 | func NewBrowseCmd(f *cmdutil.Factory) *cobra.Command { 29 | opts := &BrowseOptions{ 30 | IO: f.IOStreams, 31 | Config: f.Config, 32 | SearchClient: f.SearchClient, 33 | PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), 34 | } 35 | 36 | cmd := &cobra.Command{ 37 | Use: "browse ", 38 | Aliases: []string{"list", "l"}, 39 | Args: validators.ExactArgs(1), 40 | ValidArgsFunction: cmdutil.IndexNames(opts.SearchClient), 41 | Short: "List all synonyms in this index", 42 | Annotations: map[string]string{ 43 | "runInWebCLI": "true", 44 | "acls": "settings", 45 | }, 46 | Example: heredoc.Doc(` 47 | # List all the synonyms in the 'MOVIES' index 48 | $ algolia synonyms browse MOVIES 49 | 50 | # List all the synonyms in the 'MOVIES' index and save them in the 'synonyms.json' file 51 | $ algolia synonyms browse MOVIES > synonyms.json 52 | `), 53 | RunE: func(cmd *cobra.Command, args []string) error { 54 | opts.Index = args[0] 55 | 56 | return runBrowseCmd(opts) 57 | }, 58 | } 59 | 60 | opts.PrintFlags.AddFlags(cmd) 61 | 62 | return cmd 63 | } 64 | 65 | func runBrowseCmd(opts *BrowseOptions) error { 66 | client, err := opts.SearchClient() 67 | if err != nil { 68 | return err 69 | } 70 | // Check if index exists, because the API just returns an empty list if it doesn't 71 | exists, err := client.IndexExists(opts.Index) 72 | if err != nil { 73 | return err 74 | } 75 | if !exists { 76 | return fmt.Errorf("index %s doesn't exist", opts.Index) 77 | } 78 | 79 | p, err := opts.PrintFlags.ToPrinter() 80 | if err != nil { 81 | return err 82 | } 83 | 84 | err = client.BrowseSynonyms( 85 | opts.Index, 86 | *search.NewEmptySearchSynonymsParams(), 87 | search.WithAggregator(func(res any, _ error) { 88 | for _, synonym := range res.(*search.SearchSynonymsResponse).Hits { 89 | p.Print(opts.IO, synonym) 90 | } 91 | }), 92 | ) 93 | if err != nil { 94 | return err 95 | } 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /pkg/cmd/synonyms/browse/browse_test.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/algolia/cli/pkg/httpmock" 10 | "github.com/algolia/cli/test" 11 | ) 12 | 13 | func Test_runBrowseCmd(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | cli string 17 | hits []search.SynonymHit 18 | wantOut string 19 | }{ 20 | { 21 | name: "single synonym", 22 | cli: "foo", 23 | hits: []search.SynonymHit{{ObjectID: "foo", Type: "synonym"}}, 24 | wantOut: "{\"objectID\":\"foo\",\"type\":\"synonym\"}\n", 25 | }, 26 | { 27 | name: "multiple synonyms", 28 | cli: "foo", 29 | hits: []search.SynonymHit{ 30 | {ObjectID: "foo", Type: "synonym"}, 31 | {ObjectID: "bar", Type: "synonym"}, 32 | }, 33 | wantOut: "{\"objectID\":\"foo\",\"type\":\"synonym\"}\n{\"objectID\":\"bar\",\"type\":\"synonym\"}\n", 34 | }, 35 | } 36 | 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | r := httpmock.Registry{} 40 | // Check if index exists 41 | r.Register( 42 | httpmock.REST("GET", "1/indexes/foo/settings"), 43 | httpmock.JSONResponse(search.SettingsResponse{}), 44 | ) 45 | r.Register( 46 | httpmock.REST("POST", "1/indexes/foo/synonyms/search"), 47 | httpmock.JSONResponse(search.SearchSynonymsResponse{ 48 | Hits: tt.hits, 49 | }), 50 | ) 51 | defer r.Verify(t) 52 | 53 | f, out := test.NewFactory(true, &r, nil, "") 54 | cmd := NewBrowseCmd(f) 55 | out, err := test.Execute(cmd, tt.cli, out) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | assert.Equal(t, tt.wantOut, out.String()) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/cmd/synonyms/save/messages.go: -------------------------------------------------------------------------------- 1 | package save 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "text/template" 8 | 9 | shared "github.com/algolia/cli/pkg/cmd/synonyms/shared" 10 | "github.com/algolia/cli/pkg/utils" 11 | ) 12 | 13 | type SuccessMessage struct { 14 | Icon string 15 | Type string 16 | ID string 17 | Values string 18 | Index string 19 | } 20 | 21 | const successTemplate = `{{ .Type }} '{{ .ID }}' successfully saved with {{ .Values }} to {{ .Index }}` 22 | 23 | func GetSuccessMessage(flags shared.SynonymFlags, index string) (string, error) { 24 | var successMessage SuccessMessage 25 | 26 | if flags.SynonymType == "" || flags.SynonymType == shared.Regular { 27 | successMessage = SuccessMessage{ 28 | Type: "Synonym", 29 | ID: flags.SynonymID, 30 | Values: fmt.Sprintf("%s (%s)", 31 | utils.Pluralize(len(flags.Synonyms), "synonym"), 32 | strings.Join(flags.Synonyms, ", ")), 33 | Index: index, 34 | } 35 | } 36 | 37 | switch flags.SynonymType { 38 | case shared.OneWay, shared.AltOneWay: 39 | successMessage = SuccessMessage{ 40 | Type: "One way synonym", 41 | ID: flags.SynonymID, 42 | Values: fmt.Sprintf("input '%s' and %s (%s)", 43 | flags.SynonymInput, 44 | utils.Pluralize(len(flags.Synonyms), "synonym"), 45 | strings.Join(flags.Synonyms, ", ")), 46 | Index: index, 47 | } 48 | case shared.Placeholder: 49 | successMessage = SuccessMessage{ 50 | Type: "Placeholder synonym", 51 | ID: flags.SynonymID, 52 | Values: fmt.Sprintf("placeholder '%s' and %s (%s)", 53 | flags.SynonymPlaceholder, 54 | utils.Pluralize(len(flags.SynonymReplacements), "replacement"), 55 | strings.Join(flags.SynonymReplacements, ", ")), 56 | Index: index, 57 | } 58 | case shared.AltCorrection1, 59 | shared.AltCorrection2, 60 | shared.AltAltCorrection1, 61 | shared.AltAltCorrection2: 62 | altCorrectionType := "1" 63 | if flags.SynonymType == shared.AltCorrection2 || 64 | flags.SynonymType == shared.AltAltCorrection2 { 65 | altCorrectionType = "2" 66 | } 67 | altCorrectionType = "Alt correction " + altCorrectionType + " synonym" 68 | successMessage = SuccessMessage{ 69 | Type: altCorrectionType, 70 | ID: flags.SynonymID, 71 | Values: fmt.Sprintf("word '%s' and %s (%s)", 72 | flags.SynonymWord, 73 | utils.Pluralize(len(flags.SynonymCorrections), "correction"), 74 | strings.Join(flags.SynonymCorrections, ", ")), 75 | Index: index, 76 | } 77 | } 78 | 79 | t := template.Must(template.New("successMessage").Parse(successTemplate)) 80 | 81 | var tpl bytes.Buffer 82 | if err := t.Execute(&tpl, successMessage); err != nil { 83 | return "", err 84 | } 85 | return tpl.String() + "\n", nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/cmd/synonyms/shared/flags_to_synonym_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_FlagsToSynonym(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | synonymFlags SynonymFlags 14 | synonymType search.SynonymType 15 | wantsErr bool 16 | wantsErrMsg string 17 | }{ 18 | // Regular type 19 | { 20 | name: "Regular synonym", 21 | wantsErr: false, 22 | synonymFlags: SynonymFlags{ 23 | SynonymID: "23", 24 | Synonyms: []string{"mj", "goat"}, 25 | }, 26 | synonymType: search.SYNONYM_TYPE_SYNONYM, 27 | }, 28 | { 29 | name: "Regular synonym explicit type", 30 | wantsErr: false, 31 | synonymFlags: SynonymFlags{ 32 | SynonymType: "synonym", 33 | SynonymID: "23", 34 | Synonyms: []string{"mj", "goat"}, 35 | }, 36 | synonymType: search.SYNONYM_TYPE_SYNONYM, 37 | }, 38 | // One way type 39 | { 40 | name: "One way synonym", 41 | wantsErr: false, 42 | synonymFlags: SynonymFlags{ 43 | SynonymType: "oneWaySynonym", 44 | SynonymID: "23", 45 | Synonyms: []string{"mj", "goat"}, 46 | SynonymInput: "michael", 47 | }, 48 | synonymType: search.SYNONYM_TYPE_ONE_WAY_SYNONYM, 49 | }, 50 | // Alt correction type 51 | { 52 | name: "AltCorrection1 synonym", 53 | wantsErr: false, 54 | synonymFlags: SynonymFlags{ 55 | SynonymType: "altCorrection1", 56 | SynonymID: "23", 57 | SynonymCorrections: []string{"mj", "goat"}, 58 | SynonymWord: "michael", 59 | }, 60 | synonymType: search.SYNONYM_TYPE_ALT_CORRECTION1, 61 | }, 62 | { 63 | name: "AltCorrection2 synonym", 64 | wantsErr: false, 65 | synonymFlags: SynonymFlags{ 66 | SynonymType: "altCorrection2", 67 | SynonymID: "24", 68 | SynonymCorrections: []string{"bryant", "mamba"}, 69 | SynonymWord: "kobe", 70 | }, 71 | synonymType: search.SYNONYM_TYPE_ALT_CORRECTION2, 72 | }, 73 | // Placeholder type 74 | { 75 | name: "Placeholder synonym", 76 | wantsErr: false, 77 | synonymFlags: SynonymFlags{ 78 | SynonymType: string(search.SYNONYM_TYPE_PLACEHOLDER), 79 | SynonymID: "23", 80 | SynonymReplacements: []string{"james", "lebron"}, 81 | SynonymPlaceholder: "king", 82 | }, 83 | synonymType: search.SYNONYM_TYPE_PLACEHOLDER, 84 | }, 85 | // Wrong type 86 | { 87 | name: "Wrong synonym type", 88 | wantsErr: true, 89 | wantsErrMsg: "invalid synonym type", 90 | synonymFlags: SynonymFlags{ 91 | SynonymType: "wrongType", 92 | SynonymID: "23", 93 | SynonymReplacements: []string{"james", "lebron"}, 94 | SynonymPlaceholder: "king", 95 | }, 96 | }, 97 | } 98 | 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | synonym, err := FlagsToSynonym(tt.synonymFlags) 102 | 103 | if tt.wantsErr { 104 | assert.EqualError(t, err, tt.wantsErrMsg) 105 | return 106 | } 107 | 108 | assert.Equal(t, synonym.Type, tt.synonymType) 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pkg/cmd/synonyms/synonyms.go: -------------------------------------------------------------------------------- 1 | package synonyms 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/algolia/cli/pkg/cmd/synonyms/browse" 7 | "github.com/algolia/cli/pkg/cmd/synonyms/delete" 8 | importSynonyms "github.com/algolia/cli/pkg/cmd/synonyms/import" 9 | "github.com/algolia/cli/pkg/cmd/synonyms/save" 10 | "github.com/algolia/cli/pkg/cmdutil" 11 | ) 12 | 13 | // NewSynonymsCmd returns a new command for synonyms. 14 | func NewSynonymsCmd(f *cmdutil.Factory) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "synonyms", 17 | Aliases: []string{"synonym"}, 18 | Short: "Manage your Algolia synonyms", 19 | } 20 | 21 | cmd.AddCommand(importSynonyms.NewImportCmd(f, nil)) 22 | cmd.AddCommand(browse.NewBrowseCmd(f)) 23 | cmd.AddCommand(delete.NewDeleteCmd(f, nil)) 24 | cmd.AddCommand(save.NewSaveCmd(f, nil)) 25 | 26 | return cmd 27 | } 28 | -------------------------------------------------------------------------------- /pkg/cmdutil/category_flagset.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type CategoryFlagSet struct { 10 | Categories map[string]*pflag.FlagSet 11 | Print *pflag.FlagSet 12 | Others *pflag.FlagSet 13 | } 14 | 15 | func NewCategoryFlagSet(flags *pflag.FlagSet) *CategoryFlagSet { 16 | categories := make(map[string]*pflag.FlagSet) 17 | others := pflag.NewFlagSet("other", pflag.ContinueOnError) 18 | print := pflag.NewFlagSet("print", pflag.ContinueOnError) 19 | 20 | flags.VisitAll(func(f *pflag.Flag) { 21 | if _, ok := f.Annotations["Categories"]; ok { 22 | mainCategory := f.Annotations["Categories"][0] 23 | if _, ok := categories[mainCategory]; !ok { 24 | categories[mainCategory] = pflag.NewFlagSet(mainCategory, pflag.ContinueOnError) 25 | } 26 | categories[mainCategory].AddFlag(f) 27 | } else if _, ok := f.Annotations["IsPrint"]; ok { 28 | print.AddFlag(f) 29 | } else { 30 | others.AddFlag(f) 31 | } 32 | }) 33 | 34 | return &CategoryFlagSet{ 35 | Print: print, 36 | Categories: categories, 37 | Others: others, 38 | } 39 | } 40 | 41 | func (c *CategoryFlagSet) SortedCategoryNames() []string { 42 | var names []string 43 | for name := range c.Categories { 44 | names = append(names, name) 45 | } 46 | sort.Strings(names) 47 | return names 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cmdutil/errors.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/AlecAivazis/survey/v2/terminal" 8 | ) 9 | 10 | // FlagErrorf returns a new FlagError that wraps an error produced by 11 | // fmt.Errorf(format, args...). 12 | func FlagErrorf(format string, args ...interface{}) error { 13 | return FlagErrorWrap(fmt.Errorf(format, args...)) 14 | } 15 | 16 | // FlagError returns a new FlagError that wraps the specified error. 17 | func FlagErrorWrap(err error) error { return &FlagError{err} } 18 | 19 | // A *FlagError indicates an error processing command-line flags or other arguments. 20 | // Such errors cause the application to display the usage message. 21 | type FlagError struct { 22 | // Note: not struct{error}: only *FlagError should satisfy error. 23 | Err error 24 | } 25 | 26 | func (fe *FlagError) Error() string { 27 | return fe.Err.Error() 28 | } 29 | 30 | func (fe *FlagError) Unwrap() error { 31 | return fe.Err 32 | } 33 | 34 | // ErrSilent is an error that triggers exit code 1 without any error messaging 35 | var ErrSilent = errors.New("Error: silent") 36 | 37 | // ErrCancel signals user-initiated cancellation 38 | var ErrCancel = errors.New("Error: cancelled") 39 | 40 | func IsUserCancellation(err error) bool { 41 | return errors.Is(err, ErrCancel) || errors.Is(err, terminal.InterruptErr) 42 | } 43 | 44 | func MutuallyExclusive(message string, conditions ...bool) error { 45 | numTrue := 0 46 | for _, ok := range conditions { 47 | if ok { 48 | numTrue++ 49 | } 50 | } 51 | if numTrue > 1 { 52 | return FlagErrorf("%s", message) 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmdutil/factory.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 9 | 10 | "github.com/algolia/cli/api/crawler" 11 | "github.com/algolia/cli/pkg/config" 12 | "github.com/algolia/cli/pkg/iostreams" 13 | ) 14 | 15 | type Factory struct { 16 | IOStreams *iostreams.IOStreams 17 | Config config.IConfig 18 | SearchClient func() (*search.APIClient, error) 19 | CrawlerClient func() (*crawler.Client, error) 20 | 21 | ExecutableName string 22 | } 23 | 24 | // Executable is the path to the currently invoked binary 25 | func (f *Factory) Executable() string { 26 | if !strings.ContainsRune(f.ExecutableName, os.PathSeparator) { 27 | f.ExecutableName = executable(f.ExecutableName) 28 | } 29 | return f.ExecutableName 30 | } 31 | 32 | // based on https://github.com/cli/cli/blob/master/pkg/cmdutil/factory.go 33 | func executable(fallbackName string) string { 34 | exe, err := os.Executable() 35 | if err != nil { 36 | return fallbackName 37 | } 38 | 39 | base := filepath.Base(exe) 40 | path := os.Getenv("PATH") 41 | for _, dir := range filepath.SplitList(path) { 42 | p, err := filepath.Abs(filepath.Join(dir, base)) 43 | if err != nil { 44 | continue 45 | } 46 | f, err := os.Lstat(p) 47 | if err != nil { 48 | continue 49 | } 50 | 51 | if p == exe { 52 | return p 53 | } else if f.Mode()&os.ModeSymlink != 0 { 54 | if t, err := os.Readlink(p); err == nil && t == exe { 55 | return p 56 | } 57 | } 58 | } 59 | 60 | return exe 61 | } 62 | -------------------------------------------------------------------------------- /pkg/cmdutil/file_input.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | ) 8 | 9 | const maxCapacity = 1024 * 5120 // 5MB 10 | 11 | func ReadFile(filename string, stdin io.ReadCloser) ([]byte, error) { 12 | if filename == "-" { 13 | b, err := io.ReadAll(stdin) 14 | _ = stdin.Close() 15 | return b, err 16 | } 17 | 18 | return os.ReadFile(filename) 19 | } 20 | 21 | func ScanFile(filename string, stdin io.ReadCloser) (*bufio.Scanner, error) { 22 | var scanner *bufio.Scanner 23 | 24 | if filename == "-" { 25 | scanner = bufio.NewScanner(stdin) 26 | } else { 27 | f, err := os.Open(filename) 28 | if err != nil { 29 | return nil, err 30 | } 31 | scanner = bufio.NewScanner(f) 32 | } 33 | 34 | buffer := make([]byte, maxCapacity) 35 | scanner.Buffer(buffer, maxCapacity) 36 | return scanner, nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/cmdutil/flags_completion_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_runStringSliceCompletion(t *testing.T) { 11 | allowedMap := map[string]string{ 12 | "settings": "settings", 13 | "synonyms": "synonyms", 14 | "rules": "rules", 15 | } 16 | prefixDescription := "copy only" 17 | 18 | tests := []struct { 19 | name string 20 | toComplete string 21 | results []string 22 | }{ 23 | { 24 | name: "first input, no letter", 25 | toComplete: "", 26 | results: []string{ 27 | "rules\tcopy only rules", 28 | "settings\tcopy only settings", 29 | "synonyms\tcopy only synonyms", 30 | }, 31 | }, 32 | { 33 | name: "second input (settings already passed), no letter", 34 | toComplete: "settings,s", 35 | results: []string{"settings,synonyms\tcopy only settings and synonyms"}, 36 | }, 37 | { 38 | name: "first input, first letter", 39 | toComplete: "s", 40 | results: []string{"settings\tcopy only settings", "synonyms\tcopy only synonyms"}, 41 | }, 42 | { 43 | name: "second input (settings already passed), first letter", 44 | toComplete: "settings,s", 45 | results: []string{"settings,synonyms\tcopy only settings and synonyms"}, 46 | }, 47 | { 48 | name: "third input (settings and synonyms already passed), no letter", 49 | toComplete: "settings,synonyms,", 50 | results: []string{"settings,synonyms,rules\tcopy only settings, synonyms and rules"}, 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | results, rule := runStringSliceCompletion( 57 | allowedMap, 58 | tt.toComplete, 59 | prefixDescription, 60 | ) 61 | 62 | assert.Equal(t, tt.results, results) 63 | assert.Equal(t, cobra.ShellCompDirectiveNoSpace, rule) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pkg/cmdutil/json_flags.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/algolia/cli/pkg/printers" 9 | ) 10 | 11 | func (f *JSONPrintFlags) AllowedFormats() []string { 12 | if f == nil { 13 | return []string{} 14 | } 15 | return []string{"json"} 16 | } 17 | 18 | type JSONPrintFlags struct{} 19 | 20 | func (f *JSONPrintFlags) ToPrinter(outputFormat string) (printers.Printer, error) { 21 | var printer printers.Printer 22 | 23 | outputFormat = strings.ToLower(outputFormat) 24 | switch outputFormat { 25 | case "json": 26 | printer = &printers.JSONPrinter{} 27 | default: 28 | return nil, NoCompatiblePrinterError{ 29 | OutputFormat: &outputFormat, 30 | AllowedFormats: f.AllowedFormats(), 31 | } 32 | } 33 | 34 | return printer, nil 35 | } 36 | 37 | func (f *JSONPrintFlags) AddFlags(c *cobra.Command) {} 38 | 39 | func NewJSONPrintFlags() *JSONPrintFlags { 40 | return &JSONPrintFlags{} 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmdutil/json_value.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/algolia/cli/pkg/utils" 7 | ) 8 | 9 | // JSONValue is a flag.Value that marshals a JSON object into a string. 10 | type JSONValue struct { 11 | Value interface{} 12 | types []string 13 | } 14 | 15 | // NewJSONValue creates a new JSONVar. 16 | func NewJSONVar(types ...string) *JSONValue { 17 | return &JSONValue{ 18 | types: types, 19 | } 20 | } 21 | 22 | // String returns the string representation of the JSON object. 23 | func (j *JSONValue) String() string { 24 | b, err := json.Marshal(j.Value) 25 | if err != nil { 26 | return "failed to marshal object" 27 | } 28 | return string(b) 29 | } 30 | 31 | // Set parses the JSON string into the value. 32 | func (j *JSONValue) Set(s string) error { 33 | if err := json.Unmarshal([]byte(s), &j.Value); err != nil { 34 | if utils.Contains(j.types, "string") { 35 | j.Value = s 36 | return nil 37 | } 38 | return err 39 | } 40 | return nil 41 | } 42 | 43 | // Type returns the type of the value. 44 | func (j *JSONValue) Type() string { 45 | return "json" 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmdutil/json_value_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_JSONValue(t *testing.T) { 10 | tests := []struct { 11 | in string 12 | types []string 13 | out interface{} 14 | err bool 15 | }{ 16 | { 17 | in: "string", 18 | types: []string{ 19 | "string", 20 | }, 21 | out: "string", 22 | err: false, 23 | }, 24 | { 25 | in: "string", 26 | types: []string{}, 27 | out: nil, 28 | err: true, 29 | }, 30 | { 31 | in: `["array"]`, 32 | types: []string{}, 33 | out: []interface{}{"array"}, 34 | err: false, 35 | }, 36 | { 37 | in: "true", 38 | types: []string{}, 39 | out: true, 40 | err: false, 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | j := NewJSONVar(test.types...) 46 | err := j.Set(test.in) 47 | 48 | if err != nil && !test.err { 49 | t.Errorf("unexpected error: %v", err) 50 | } 51 | if err == nil && test.err { 52 | t.Errorf("expected error, got none") 53 | } 54 | 55 | assert.Equal(t, test.out, j.Value) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/cmdutil/map_flags.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | 6 | "github.com/algolia/cli/pkg/utils" 7 | ) 8 | 9 | // FlagValuesMap returns a map of flag values for the given FlagSet. 10 | func FlagValuesMap(flags *pflag.FlagSet, only ...string) (map[string]interface{}, error) { 11 | values := make(map[string]interface{}) 12 | 13 | flags.Visit(func(flag *pflag.Flag) { 14 | // Skip if we only want to load a subset of flags. 15 | if only != nil && !utils.Contains(only, flag.Name) { 16 | return 17 | } 18 | switch flag.Value.Type() { 19 | case "string": 20 | val, err := flags.GetString(flag.Name) 21 | if err == nil { 22 | values[flag.Name] = val 23 | } 24 | case "int": 25 | val, err := flags.GetInt(flag.Name) 26 | if err == nil { 27 | values[flag.Name] = val 28 | } 29 | case "bool": 30 | val, err := flags.GetBool(flag.Name) 31 | if err == nil { 32 | values[flag.Name] = val 33 | } 34 | case "float64": 35 | val, err := flags.GetFloat64(flag.Name) 36 | if err == nil { 37 | values[flag.Name] = val 38 | } 39 | case "stringSlice": 40 | val, err := flags.GetStringSlice(flag.Name) 41 | if err == nil { 42 | values[flag.Name] = val 43 | } 44 | case "intSlice": 45 | val, err := flags.GetIntSlice(flag.Name) 46 | if err == nil { 47 | values[flag.Name] = val 48 | } 49 | case "boolSlice": 50 | val, err := flags.GetBoolSlice(flag.Name) 51 | if err == nil { 52 | values[flag.Name] = val 53 | } 54 | case "float64Slice": 55 | val, err := flags.GetFloat64Slice(flag.Name) 56 | if err == nil { 57 | values[flag.Name] = val 58 | } 59 | case "json": 60 | values[flag.Name] = flag.Value.(*JSONValue).Value 61 | default: 62 | panic("unsupported flag type") 63 | } 64 | }) 65 | return values, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/cmdutil/map_flags_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/pflag" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_FlagValuesMap(t *testing.T) { 11 | flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) 12 | flagSet.String("string", "", "") 13 | flagSet.Int("int", 0, "") 14 | flagSet.Bool("bool", false, "") 15 | flagSet.Float64("float64", 0.0, "") 16 | flagSet.StringSlice("stringSlice", []string{}, "") 17 | flagSet.IntSlice("intSlice", []int{}, "") 18 | flagSet.BoolSlice("boolSlice", []bool{}, "") 19 | flagSet.Float64Slice("float64Slice", []float64{}, "") 20 | flagSet.Var(&JSONValue{}, "json", "") 21 | 22 | _ = flagSet.Set("string", "string") 23 | _ = flagSet.Set("int", "1") 24 | _ = flagSet.Set("bool", "true") 25 | _ = flagSet.Set("float64", "1.0") 26 | _ = flagSet.Set("stringSlice", "string") 27 | _ = flagSet.Set("intSlice", "1") 28 | _ = flagSet.Set("boolSlice", "true") 29 | _ = flagSet.Set("float64Slice", "1.0") 30 | _ = flagSet.Set("json", `["json"]`) 31 | 32 | flagValuesMap, err := FlagValuesMap(flagSet) 33 | if err != nil { 34 | t.Errorf("unexpected error: %v", err) 35 | } 36 | 37 | assert.Equal(t, "string", flagValuesMap["string"]) 38 | assert.Equal(t, 1, flagValuesMap["int"]) 39 | assert.Equal(t, true, flagValuesMap["bool"]) 40 | assert.Equal(t, 1.0, flagValuesMap["float64"]) 41 | assert.Equal(t, []string{"string"}, flagValuesMap["stringSlice"]) 42 | assert.Equal(t, []int{1}, flagValuesMap["intSlice"]) 43 | assert.Equal(t, []bool{true}, flagValuesMap["boolSlice"]) 44 | assert.Equal(t, []float64{1.0}, flagValuesMap["float64Slice"]) 45 | assert.Equal(t, []interface{}{"json"}, flagValuesMap["json"]) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmdutil/print_flags.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/algolia/cli/pkg/printers" 11 | ) 12 | 13 | // IsNoCompatiblePrinterError returns true if it is a not a compatible printer 14 | // otherwise it will return false 15 | func IsNoCompatiblePrinterError(err error) bool { 16 | if err == nil { 17 | return false 18 | } 19 | 20 | _, ok := err.(NoCompatiblePrinterError) 21 | return ok 22 | } 23 | 24 | type PrintFlags struct { 25 | JSONPrintFlags *JSONPrintFlags 26 | JSONPathPrintFlags *JSONPathPrintFlags 27 | 28 | OutputFormat *string 29 | OutputFlagSpecified func() bool 30 | } 31 | 32 | // NoCompatiblePrinterError is a struct that contains error information. 33 | // It will be constructed when a invalid printing format is provided 34 | type NoCompatiblePrinterError struct { 35 | OutputFormat *string 36 | AllowedFormats []string 37 | Options interface{} 38 | } 39 | 40 | func (e NoCompatiblePrinterError) Error() string { 41 | output := "" 42 | if e.OutputFormat != nil { 43 | output = *e.OutputFormat 44 | } 45 | 46 | sort.Strings(e.AllowedFormats) 47 | return fmt.Sprintf( 48 | "unable to match a printer suitable for the output format %q, allowed formats are: %s", 49 | output, 50 | strings.Join(e.AllowedFormats, ","), 51 | ) 52 | } 53 | 54 | func (f *PrintFlags) AllowedFormats() []string { 55 | ret := []string{} 56 | ret = append(ret, f.JSONPrintFlags.AllowedFormats()...) 57 | ret = append(ret, f.JSONPathPrintFlags.AllowedFormats()...) 58 | return ret 59 | } 60 | 61 | func (f *PrintFlags) ToPrinter() (printers.Printer, error) { 62 | outputFormat := "" 63 | if f.OutputFormat != nil { 64 | outputFormat = *f.OutputFormat 65 | } 66 | 67 | if f.JSONPrintFlags != nil { 68 | if p, err := f.JSONPrintFlags.ToPrinter(outputFormat); !IsNoCompatiblePrinterError(err) { 69 | return p, err 70 | } 71 | } 72 | 73 | if f.JSONPathPrintFlags != nil { 74 | if p, err := f.JSONPathPrintFlags.ToPrinter(outputFormat); !IsNoCompatiblePrinterError( 75 | err, 76 | ) { 77 | return p, err 78 | } 79 | } 80 | 81 | return nil, NoCompatiblePrinterError{ 82 | OutputFormat: f.OutputFormat, 83 | AllowedFormats: f.AllowedFormats(), 84 | } 85 | } 86 | 87 | func (f *PrintFlags) AddFlags(cmd *cobra.Command) { 88 | f.JSONPrintFlags.AddFlags(cmd) 89 | f.JSONPathPrintFlags.AddFlags(cmd) 90 | 91 | if f.OutputFormat != nil { 92 | cmd.Flags(). 93 | StringVarP(f.OutputFormat, "output", "o", *f.OutputFormat, fmt.Sprintf(`Output format. One of: (%s).`, strings.Join(f.AllowedFormats(), ", "))) 94 | _ = cmd.Flags().SetAnnotation("output", "IsPrint", []string{"true"}) 95 | if f.OutputFlagSpecified == nil { 96 | f.OutputFlagSpecified = func() bool { 97 | return cmd.Flag("output").Changed 98 | } 99 | } 100 | } 101 | } 102 | 103 | // WithDefaultOutput sets a default output format if one is not provided through a flag value 104 | func (f *PrintFlags) WithDefaultOutput(output string) *PrintFlags { 105 | f.OutputFormat = &output 106 | return f 107 | } 108 | 109 | // NewPrintFlags returns a default *PrintFlags 110 | func NewPrintFlags() *PrintFlags { 111 | outputFormat := "" 112 | 113 | return &PrintFlags{ 114 | OutputFormat: &outputFormat, 115 | 116 | JSONPrintFlags: NewJSONPrintFlags(), 117 | JSONPathPrintFlags: NewJSONPathPrintFlags(), 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /pkg/cmdutil/telemetry_check.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | func ShouldTrackUsage(cmd *cobra.Command) bool { 6 | switch cmd.Name() { 7 | case "help", "powershell", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: 8 | return false 9 | } 10 | 11 | if cmd.Parent() != nil && cmd.Parent().Name() == "completion" { 12 | return false 13 | } 14 | 15 | return true 16 | } 17 | -------------------------------------------------------------------------------- /pkg/cmdutil/valid_args.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 7 | "github.com/algolia/cli/api/crawler" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // IndexNames returns a function to list the index names from the given search client. 12 | func IndexNames( 13 | clientF func() (*search.APIClient, error), 14 | ) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 15 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 16 | client, err := clientF() 17 | if err != nil { 18 | return nil, cobra.ShellCompDirectiveError 19 | } 20 | res, err := client.ListIndices(client.NewApiListIndicesRequest()) 21 | if err != nil { 22 | return nil, cobra.ShellCompDirectiveError 23 | } 24 | 25 | names := make([]string, 0, len(res.Items)) 26 | for _, index := range res.Items { 27 | names = append(names, index.Name) 28 | } 29 | return names, cobra.ShellCompDirectiveNoFileComp 30 | } 31 | } 32 | 33 | // CrawlerIDs returns a function to list the crawler IDs from the given crawler client. 34 | func CrawlerIDs( 35 | clientF func() (*crawler.Client, error), 36 | ) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 37 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 38 | client, err := clientF() 39 | if err != nil { 40 | return nil, cobra.ShellCompDirectiveError 41 | } 42 | items, err := client.ListAll("", "") 43 | if err != nil { 44 | return nil, cobra.ShellCompDirectiveError 45 | } 46 | 47 | names := make([]string, 0, len(items)) 48 | for _, crawler := range items { 49 | names = append(names, fmt.Sprintf("%s\t%s", crawler.ID, crawler.Name)) 50 | } 51 | return names, cobra.ShellCompDirectiveNoFileComp 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/config/validators.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrAPIKeyNotConfigured is the error returned when the loaded profile is missing the api_key property 9 | ErrAPIKeyNotConfigured = errors.New("you have not configured your API key yet") 10 | // ErrApplicationIDNotConfigured is the error returned when the loaded profile is missing the application_id property 11 | ErrApplicationIDNotConfigured = errors.New("you have not configured your Application ID yet") 12 | 13 | // ErrCrawlerAPIKeyNotConfigured is the error returned when the loaded profile is missing the crawler_api_key property 14 | ErrCrawlerAPIKeyNotConfigured = errors.New("you have not configured your Crawler API key yet") 15 | // ErrCrawlerUserIDNotConfigured is the error returned when the loaded profile is missing the crawler_user_id property 16 | ErrCrawlerUserIDNotConfigured = errors.New("you have not configured your Crawler user ID yet") 17 | ) 18 | 19 | // AdminAPIKey validates that a string looks like an Admin API key. 20 | func AdminAPIKey(input string) error { 21 | if len(input) == 0 { 22 | return ErrAPIKeyNotConfigured 23 | } else if len(input) != 32 { 24 | return errors.New("the provided API key looks wrong, it must be 32 characters long") 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/gen/flags.go.tpl: -------------------------------------------------------------------------------- 1 | // This file is generated; DO NOT EDIT. 2 | 3 | package cmdutil 4 | 5 | import ( 6 | "github.com/spf13/cobra" 7 | "github.com/MakeNowJust/heredoc" 8 | ) 9 | {{ range $resName, $resData := .SpecFlags }} 10 | var {{ $resName | capitalize }} = []string{ 11 | {{ range $flagName, $flag := $resData.Flags }}"{{ $flagName }}", 12 | {{ end }} 13 | } 14 | {{ end }} 15 | 16 | {{ range $resName, $resData := .SpecFlags }} 17 | func Add{{ $resName | capitalize }}Flags(cmd *cobra.Command) { {{ range $flagName, $flag := $resData.Flags }}{{ if eq $flag.Type "string" }} 18 | cmd.Flags().String("{{ $flagName }}", {{ if $flag.Def }}"{{ $flag.Def }}"{{ else }}""{{ end }}, heredoc.Doc(`{{ $flag.Usage }}`)){{ if $flag.Categories }} 19 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ else if eq $flag.Type "boolean" }} 20 | cmd.Flags().Bool("{{ $flagName }}", {{ $flag.Def }}, heredoc.Doc(`{{ $flag.Usage }}`)){{ if $flag.Categories }} 21 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ else if eq $flag.Type "integer" }} 22 | cmd.Flags().Int("{{ $flagName }}", {{ if $flag.Def }}{{ $flag.Def }}{{ else }}0{{ end }}, heredoc.Doc(`{{ $flag.Usage }}`)){{ if $flag.Categories }} 23 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ else if eq $flag.Type "number" }} 24 | cmd.Flags().Float64("{{ $flagName }}", {{ if $flag.Def }}{{ $flag.Def }}{{ else }}0{{ end }}, heredoc.Doc(`{{ $flag.Usage }}`)){{ if $flag.Categories }} 25 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ else if eq $flag.Type "array" }}{{ if eq $flag.SubType "string" }} 26 | cmd.Flags().StringSlice("{{ $flagName }}", []string{ {{ range $val := $flag.Def }}"{{ $val }}",{{ end }} }, heredoc.Doc(`{{ $flag.Usage }}`)){{ end }}{{ if eq $flag.SubType "integer" }} 27 | cmd.Flags().IntSlice("{{ $flagName }}", []int{ {{ range $val := $flag.Def }}{{ $val }},{{ end }} }, heredoc.Doc(`{{ $flag.Usage }}`)){{ end }}{{ if eq $flag.SubType "number" }} 28 | cmd.Flags().Float64Slice("{{ $flagName }}", []float64{ {{ range $val := $flag.Def }}{{ $val }},{{ end }} }, heredoc.Doc(`{{ $flag.Usage }}`)){{ end }}{{ if $flag.Categories }} 29 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ else }} 30 | {{ $flagName }} := NewJSONVar([]string{ {{ range $val := $flag.OneOf }}"{{ $val }}",{{ end }} }...) 31 | cmd.Flags().Var({{ $flagName }}, "{{ $flagName }}", heredoc.Doc(`{{ $flag.Usage }}`)){{ if $flag.Categories }} 32 | cmd.Flags().SetAnnotation("{{ $flagName }}", "Categories", []string{ {{ range $category := $flag.Categories }}"{{ $category }}", {{ end }} }){{ end }}{{ end }}{{ end }} 33 | } 34 | {{ end }} 35 | -------------------------------------------------------------------------------- /pkg/httpmock/registry.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type Registry struct { 11 | mu sync.Mutex 12 | stubs []*Stub 13 | Requests []*http.Request 14 | } 15 | 16 | func (r *Registry) Register(m Matcher, resp Responder) { 17 | r.stubs = append(r.stubs, &Stub{ 18 | Matcher: m, 19 | Responder: resp, 20 | }) 21 | } 22 | 23 | type Testing interface { 24 | Errorf(string, ...interface{}) 25 | Helper() 26 | } 27 | 28 | func (r *Registry) Verify(t Testing) { 29 | n := 0 30 | for _, s := range r.stubs { 31 | if !s.matched { 32 | n++ 33 | } 34 | } 35 | if n > 0 { 36 | t.Helper() 37 | t.Errorf("%d unmatched HTTP stubs", n) 38 | } 39 | } 40 | 41 | // Request satisfies Requester interface 42 | func (r *Registry) Request( 43 | req *http.Request, 44 | _ time.Duration, 45 | _ time.Duration, 46 | ) (*http.Response, error) { 47 | var stub *Stub 48 | 49 | r.mu.Lock() 50 | for _, s := range r.stubs { 51 | if s.matched || !s.Matcher(req) { 52 | continue 53 | } 54 | 55 | stub = s 56 | break 57 | } 58 | if stub != nil { 59 | stub.matched = true 60 | } 61 | 62 | if stub == nil { 63 | r.mu.Unlock() 64 | return nil, fmt.Errorf("no registered stubs matched %v", req) 65 | } 66 | 67 | r.Requests = append(r.Requests, req) 68 | r.mu.Unlock() 69 | 70 | return stub.Responder(req) 71 | } 72 | 73 | // RoundTrip satisfies http.RoundTripper 74 | func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { 75 | var stub *Stub 76 | 77 | r.mu.Lock() 78 | for _, s := range r.stubs { 79 | if s.matched || !s.Matcher(req) { 80 | continue 81 | } 82 | if stub != nil { 83 | r.mu.Unlock() 84 | return nil, fmt.Errorf("more than 1 stub matched %v", req) 85 | } 86 | stub = s 87 | } 88 | if stub != nil { 89 | stub.matched = true 90 | } 91 | 92 | if stub == nil { 93 | r.mu.Unlock() 94 | return nil, fmt.Errorf("no registered stubs matched %v", req) 95 | } 96 | 97 | r.Requests = append(r.Requests, req) 98 | r.mu.Unlock() 99 | 100 | return stub.Responder(req) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/httpmock/stub.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type ( 12 | Matcher func(req *http.Request) bool 13 | Responder func(req *http.Request) (*http.Response, error) 14 | ) 15 | 16 | type Stub struct { 17 | matched bool 18 | Matcher Matcher 19 | Responder Responder 20 | } 21 | 22 | func REST(method, p string) Matcher { 23 | return func(req *http.Request) bool { 24 | if !strings.EqualFold(req.Method, method) { 25 | return false 26 | } 27 | if req.URL.Path != "/"+p { 28 | return false 29 | } 30 | return true 31 | } 32 | } 33 | 34 | func StringResponse(body string) Responder { 35 | return func(req *http.Request) (*http.Response, error) { 36 | return httpResponse(200, req, bytes.NewBufferString(body)), nil 37 | } 38 | } 39 | 40 | func JSONResponse(body interface{}) Responder { 41 | return func(req *http.Request) (*http.Response, error) { 42 | b, _ := json.Marshal(body) 43 | return httpResponse(200, req, bytes.NewBuffer(b)), nil 44 | } 45 | } 46 | 47 | func ErrorResponse() Responder { 48 | return func(req *http.Request) (*http.Response, error) { 49 | return httpResponse(404, req, bytes.NewBufferString("")), nil 50 | } 51 | } 52 | 53 | func ErrorResponseWithBody(body interface{}) Responder { 54 | return func(req *http.Request) (*http.Response, error) { 55 | b, _ := json.Marshal(body) 56 | return httpResponse(400, req, bytes.NewBuffer(b)), nil 57 | } 58 | } 59 | 60 | func httpResponse(status int, req *http.Request, body io.Reader) *http.Response { 61 | return &http.Response{ 62 | StatusCode: status, 63 | Request: req, 64 | Body: io.NopCloser(body), 65 | Header: http.Header{}, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/iostreams/console.go: -------------------------------------------------------------------------------- 1 | package iostreams 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | func (s *IOStreams) EnableVirtualTerminalProcessing() error { 9 | return nil 10 | } 11 | 12 | func HasAlternateScreenBuffer(hasTrueColor bool) bool { 13 | // on Windows we just assume that alternate screen buffer is supported if we 14 | // enabled virtual terminal processing, which in turn enables truecolor 15 | return hasTrueColor 16 | } 17 | 18 | func enableVirtualTerminalProcessing(f *os.File) error { 19 | return errors.New("not implemented") 20 | } 21 | -------------------------------------------------------------------------------- /pkg/iostreams/iostreams_test.go: -------------------------------------------------------------------------------- 1 | package iostreams 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestIOStreams_ForceTerminal(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | iostreams *IOStreams 12 | arg string 13 | wantTTY bool 14 | wantWidth int 15 | }{ 16 | { 17 | name: "explicit width", 18 | iostreams: &IOStreams{}, 19 | arg: "72", 20 | wantTTY: true, 21 | wantWidth: 72, 22 | }, 23 | { 24 | name: "measure width", 25 | iostreams: &IOStreams{ 26 | ttySize: func() (int, int, error) { 27 | return 72, 0, nil 28 | }, 29 | }, 30 | arg: "true", 31 | wantTTY: true, 32 | wantWidth: 72, 33 | }, 34 | { 35 | name: "measure width fails", 36 | iostreams: &IOStreams{ 37 | ttySize: func() (int, int, error) { 38 | return -1, -1, errors.New("ttySize sabotage!") 39 | }, 40 | }, 41 | arg: "true", 42 | wantTTY: true, 43 | wantWidth: 80, 44 | }, 45 | { 46 | name: "apply percentage", 47 | iostreams: &IOStreams{ 48 | ttySize: func() (int, int, error) { 49 | return 72, 0, nil 50 | }, 51 | }, 52 | arg: "50%", 53 | wantTTY: true, 54 | wantWidth: 36, 55 | }, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | tt.iostreams.ForceTerminal(tt.arg) 60 | if isTTY := tt.iostreams.IsStdoutTTY(); isTTY != tt.wantTTY { 61 | t.Errorf("IOStreams.IsStdoutTTY() = %v, want %v", isTTY, tt.wantTTY) 62 | } 63 | if tw := tt.iostreams.TerminalWidth(); tw != tt.wantWidth { 64 | t.Errorf("IOStreams.TerminalWidth() = %v, want %v", tw, tt.wantWidth) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/iostreams/tty_size.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package iostreams 5 | 6 | import ( 7 | "os" 8 | 9 | "golang.org/x/term" 10 | ) 11 | 12 | // ttySize measures the size of the controlling terminal for the current process 13 | func ttySize() (int, int, error) { 14 | f, err := os.Open("/dev/tty") 15 | if err != nil { 16 | return -1, -1, err 17 | } 18 | defer f.Close() 19 | return term.GetSize(int(f.Fd())) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/iostreams/tty_size_windows.go: -------------------------------------------------------------------------------- 1 | package iostreams 2 | 3 | import ( 4 | "errors" 5 | 6 | "golang.org/x/term" 7 | ) 8 | 9 | func ttySize() (int, int, error) { 10 | // in case we are not in a terminal 11 | if !term.IsTerminal(0) { 12 | return -1, -1, errors.New("not a terminal") 13 | } 14 | 15 | return term.GetSize(0) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/jsoncolor/jsoncolor.go: -------------------------------------------------------------------------------- 1 | package jsoncolor 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | colorDelim = "1;38" // bright white 12 | colorKey = "1;34" // bright blue 13 | colorNull = "36" // cyan 14 | colorString = "32" // green 15 | colorBool = "33" // yellow 16 | ) 17 | 18 | // Write colorized JSON output parsed from reader 19 | func Write(w io.Writer, r io.Reader, indent string) error { 20 | dec := json.NewDecoder(r) 21 | dec.UseNumber() 22 | 23 | var idx int 24 | var stack []json.Delim 25 | 26 | for { 27 | t, err := dec.Token() 28 | if err == io.EOF { 29 | break 30 | } 31 | if err != nil { 32 | return err 33 | } 34 | 35 | switch tt := t.(type) { 36 | case json.Delim: 37 | switch tt { 38 | case '{', '[': 39 | stack = append(stack, tt) 40 | idx = 0 41 | fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt) 42 | if dec.More() { 43 | fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack))) 44 | } 45 | continue 46 | case '}', ']': 47 | stack = stack[:len(stack)-1] 48 | idx = 0 49 | fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt) 50 | } 51 | default: 52 | b, err := json.Marshal(tt) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0 58 | idx++ 59 | 60 | var color string 61 | if isKey { 62 | color = colorKey 63 | } else if tt == nil { 64 | color = colorNull 65 | } else { 66 | switch t.(type) { 67 | case string: 68 | color = colorString 69 | case bool: 70 | color = colorBool 71 | } 72 | } 73 | 74 | if color == "" { 75 | _, _ = w.Write(b) 76 | } else { 77 | fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", color, b) 78 | } 79 | 80 | if isKey { 81 | fmt.Fprintf(w, "\x1b[%sm:\x1b[m ", colorDelim) 82 | continue 83 | } 84 | } 85 | 86 | if dec.More() { 87 | fmt.Fprintf(w, "\x1b[%sm,\x1b[m\n%s", colorDelim, strings.Repeat(indent, len(stack))) 88 | } else if len(stack) > 0 { 89 | fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1)) 90 | } else { 91 | fmt.Fprint(w, "\n") 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/open/open.go: -------------------------------------------------------------------------------- 1 | package open 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "runtime" 7 | ) 8 | 9 | var execCommand = exec.Command 10 | 11 | // Browser takes a url and opens it using the default browser on the operating system 12 | func Browser(url string) error { 13 | var err error 14 | 15 | switch runtime.GOOS { 16 | case "linux": 17 | err = execCommand("xdg-open", url).Start() 18 | case "windows": 19 | err = execCommand("rundll32", "url.dll,FileProtocolHandler", url).Start() 20 | case "darwin": 21 | err = execCommand("open", url).Start() 22 | default: 23 | err = fmt.Errorf("unsupported platform") 24 | } 25 | 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // CanOpenBrowser determines if no browser is set in linux 34 | func CanOpenBrowser() bool { 35 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { 36 | return true 37 | } 38 | 39 | output, err := execCommand("xdg-settings", "get", "default-web-browser").Output() 40 | if err != nil { 41 | return false 42 | } 43 | 44 | if string(output) == "" { 45 | return false 46 | } 47 | 48 | return true 49 | } 50 | -------------------------------------------------------------------------------- /pkg/printers/interface.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "github.com/algolia/cli/pkg/iostreams" 5 | ) 6 | 7 | // PrinterFunc is a function that can print interfaces 8 | type PrinterFunc func(interface{}, *iostreams.IOStreams) error 9 | 10 | // Print implements Printer 11 | func (fn PrinterFunc) Print(obj interface{}, io *iostreams.IOStreams) error { 12 | return fn(obj, io) 13 | } 14 | 15 | // Printer is an interface that knows how to print interfaces. 16 | type Printer interface { 17 | // Print receives an interface, formats it and prints it. 18 | Print(*iostreams.IOStreams, interface{}) error 19 | } 20 | 21 | // PrintOptions struct defines a struct for various print options 22 | type PrintOptions struct { 23 | NoHeaders bool 24 | ColumnLabels []string 25 | } 26 | -------------------------------------------------------------------------------- /pkg/printers/json.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | 8 | "github.com/algolia/cli/pkg/iostreams" 9 | "github.com/algolia/cli/pkg/jsoncolor" 10 | ) 11 | 12 | // JSONPrinter is an implementation of Printer which outputs an object as JSON. 13 | var _ Printer = &JSONPrinter{} 14 | 15 | type JSONPrinter struct{} 16 | 17 | type JSONPrinterOptions struct { 18 | Template string 19 | } 20 | 21 | // Print is an implementation of Printer.Print which simply writes the object to the Writer. 22 | func (p *JSONPrinter) Print(ios *iostreams.IOStreams, data interface{}) error { 23 | buf := bytes.Buffer{} 24 | encoder := json.NewEncoder(&buf) 25 | encoder.SetEscapeHTML(false) 26 | 27 | if err := encoder.Encode(data); err != nil { 28 | return err 29 | } 30 | 31 | w := ios.Out 32 | if ios.ColorEnabled() { 33 | return jsoncolor.Write(w, &buf, " ") 34 | } 35 | 36 | _, err := io.Copy(w, &buf) 37 | return err 38 | } 39 | -------------------------------------------------------------------------------- /pkg/printers/jsonpath.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/algolia/cli/pkg/iostreams" 8 | "k8s.io/client-go/util/jsonpath" 9 | ) 10 | 11 | // JSONPathPrinter is an implementation of ResourcePrinter which formats data with a JSONPath template. 12 | var _ Printer = &JSONPathPrinter{} 13 | 14 | type JSONPathPrinter struct { 15 | rawTemplate string 16 | JSONPath *jsonpath.JSONPath 17 | } 18 | 19 | func NewJSONPathPrinter(tmpl string) (*JSONPathPrinter, error) { 20 | j := jsonpath.New("out") 21 | if err := j.Parse(tmpl); err != nil { 22 | return nil, err 23 | } 24 | return &JSONPathPrinter{ 25 | rawTemplate: tmpl, 26 | JSONPath: j, 27 | }, nil 28 | } 29 | 30 | // Print formats the interface with the JSONPath Template. 31 | func (j *JSONPathPrinter) Print(ios *iostreams.IOStreams, data interface{}) error { 32 | err := j.JSONPath.Execute(ios.Out, data) 33 | if err == nil { 34 | _, _ = ios.Out.Write([]byte("\n")) 35 | } else { 36 | buf := bytes.NewBuffer(nil) 37 | fmt.Fprintf(buf, "Error executing template: %v. Printing more information for debugging the template:\n", err) 38 | fmt.Fprintf(buf, "\ttemplate was:\n\t\t%v\n", j.rawTemplate) 39 | fmt.Fprintf(buf, "\tobject given to jsonpath engine was:\n\t\t%#v\n\n", data) 40 | return fmt.Errorf("error executing jsonpath %q: %v", j.rawTemplate, buf.String()) 41 | } 42 | return nil 43 | } 44 | 45 | // AllowMissingKeys tells the template engine if missing keys are allowed. 46 | func (j *JSONPathPrinter) AllowMissingKeys(allow bool) { 47 | j.JSONPath.AllowMissingKeys(allow) 48 | } 49 | 50 | // EnableJSONOutput enables JSON output. 51 | func (j *JSONPathPrinter) EnableJSONOutput(enable bool) { 52 | j.JSONPath.EnableJSONOutput(enable) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/printers/table_printer_test.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func Test_ttyTablePrinter_truncate(t *testing.T) { 9 | buf := bytes.Buffer{} 10 | tp := &ttyTablePrinter{ 11 | out: &buf, 12 | maxWidth: 5, 13 | } 14 | 15 | tp.AddField("1", nil, nil) 16 | tp.AddField("hello", nil, nil) 17 | tp.EndRow() 18 | tp.AddField("2", nil, nil) 19 | tp.AddField("world", nil, nil) 20 | tp.EndRow() 21 | 22 | err := tp.Render() 23 | if err != nil { 24 | t.Fatalf("unexpected error: %v", err) 25 | } 26 | 27 | expected := "1 he\n2 wo\n" 28 | if buf.String() != expected { 29 | t.Errorf("expected: %q, got: %q", expected, buf.String()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/printers/template.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "text/template" 8 | 9 | "github.com/algolia/cli/pkg/iostreams" 10 | ) 11 | 12 | // GoTemplatePrinter is an implementation of ResourcePrinter which formats data with a Go Template. 13 | var _ Printer = &GoTemplatePrinter{} 14 | 15 | type GoTemplatePrinter struct { 16 | rawTemplate string 17 | template *template.Template 18 | } 19 | 20 | func NewGoTemplatePrinter(tmpl []byte) (*GoTemplatePrinter, error) { 21 | t, err := template.New("output").Parse(string(tmpl)) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return &GoTemplatePrinter{ 26 | rawTemplate: string(tmpl), 27 | template: t, 28 | }, nil 29 | } 30 | 31 | // AllowMissingKeys tells the template engine if missing keys are allowed. 32 | func (p *GoTemplatePrinter) AllowMissingKeys(allow bool) { 33 | if allow { 34 | p.template.Option("missingkey=default") 35 | } else { 36 | p.template.Option("missingkey=error") 37 | } 38 | } 39 | 40 | // Print formats the interface with the Go Template. 41 | func (p *GoTemplatePrinter) Print(ios *iostreams.IOStreams, data interface{}) error { 42 | dataM, err := json.Marshal(data) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | out := map[string]interface{}{} 48 | if err := json.Unmarshal(dataM, &out); err != nil { 49 | return err 50 | } 51 | if err = p.safeExecute(ios.Out, out); err != nil { 52 | // It is way easier to debug this stuff when it shows up in 53 | // stdout instead of just stdin. So in addition to returning 54 | // a nice error, also print useful stuff with the writer. 55 | fmt.Fprintf( 56 | ios.ErrOut, 57 | "Error executing template: %v. Printing more information for debugging the template:\n", 58 | err, 59 | ) 60 | fmt.Fprintf(ios.ErrOut, "\ttemplate was:\n\t\t%v\n", p.rawTemplate) 61 | fmt.Fprintf(ios.ErrOut, "\traw data was:\n\t\t%v\n", string(dataM)) 62 | fmt.Fprintf(ios.ErrOut, "\tobject given to template engine was:\n\t\t%+v\n\n", out) 63 | return fmt.Errorf("error executing template %q: %v", p.rawTemplate, err) 64 | } 65 | return nil 66 | } 67 | 68 | // safeExecute tries to execute the template, but catches panics and returns an error 69 | // should the template engine panic. 70 | func (p *GoTemplatePrinter) safeExecute(w io.Writer, obj interface{}) error { 71 | var panicErr error 72 | retErr := func() error { 73 | defer func() { 74 | if x := recover(); x != nil { 75 | panicErr = fmt.Errorf("caught panic: %+v", x) 76 | } 77 | }() 78 | return p.template.Execute(w, obj) 79 | }() 80 | if panicErr != nil { 81 | return panicErr 82 | } 83 | return retErr 84 | } 85 | -------------------------------------------------------------------------------- /pkg/prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import "github.com/AlecAivazis/survey/v2" 4 | 5 | func StubConfirm(result bool) func() { 6 | orig := Confirm 7 | Confirm = func(_ string, r *bool) error { 8 | *r = result 9 | return nil 10 | } 11 | return func() { 12 | Confirm = orig 13 | } 14 | } 15 | 16 | var Confirm = func(prompt string, result *bool) error { 17 | p := &survey.Confirm{ 18 | Message: prompt, 19 | Default: true, 20 | } 21 | return SurveyAskOne(p, result) 22 | } 23 | 24 | var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { 25 | return survey.AskOne(p, response, opts...) 26 | } 27 | 28 | var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { 29 | return survey.Ask(qs, response, opts...) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/telemetry/telemetry_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/segmentio/analytics-go/v3" 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // Context-related tests. 13 | func TestEventMetadataWithGet(t *testing.T) { 14 | ctx := context.Background() 15 | event := &CLIAnalyticsEventMetadata{ 16 | UserID: "user-id", 17 | InvocationID: "invocation-id", 18 | OS: "os", 19 | CLIVersion: "cli-version", 20 | CommandPath: "command-path", 21 | CommandFlags: []string{"flag1", "flag2"}, 22 | AppID: "app-id", 23 | ConfiguredApplicationsNb: 1, 24 | } 25 | newCtx := WithEventMetadata(ctx, event) 26 | 27 | // Check that the event is correctly set in the context. 28 | require.Equal(t, event, GetEventMetadata(newCtx)) 29 | } 30 | 31 | func TestEventMetadata_DoesNotExistsInContext(t *testing.T) { 32 | ctx := context.Background() 33 | require.Nil(t, GetEventMetadata(ctx)) 34 | } 35 | 36 | func TestTelemetryClientWithGet(t *testing.T) { 37 | ctx := context.Background() 38 | 39 | client, err := analytics.NewWithConfig("", analytics.Config{ 40 | Endpoint: "http://hello.com", 41 | }) 42 | require.NoError(t, err) 43 | 44 | telemetryClient := &AnalyticsTelemetryClient{client: client} 45 | newCtx := WithTelemetryClient(ctx, telemetryClient) 46 | 47 | require.Equal(t, GetTelemetryClient(newCtx), telemetryClient) 48 | } 49 | 50 | func TestSetCobraCommandContext(t *testing.T) { 51 | event := NewEventMetadata() 52 | cmd := &cobra.Command{ 53 | Use: "foo", 54 | } 55 | cmd.Flags().String("bar", "bar", "bar flag") 56 | cmd.SetArgs([]string{"--bar", "bar"}) 57 | _, err := cmd.ExecuteC() 58 | require.NoError(t, err) 59 | 60 | event.SetCobraCommandContext(cmd) 61 | 62 | require.Equal(t, "foo", event.CommandPath) 63 | require.Equal(t, []string{"bar"}, event.CommandFlags) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/text/indent.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var lineRE = regexp.MustCompile(`(?m)^`) 9 | 10 | func Indent(s, indent string) string { 11 | if len(strings.TrimSpace(s)) == 0 { 12 | return s 13 | } 14 | return lineRE.ReplaceAllLiteralString(s, indent) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/text/indent_test.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import "testing" 4 | 5 | func Test_Indent(t *testing.T) { 6 | type args struct { 7 | s string 8 | indent string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want string 14 | }{ 15 | { 16 | name: "empty", 17 | args: args{ 18 | s: "", 19 | indent: "--", 20 | }, 21 | want: "", 22 | }, 23 | { 24 | name: "blank", 25 | args: args{ 26 | s: "\n", 27 | indent: "--", 28 | }, 29 | want: "\n", 30 | }, 31 | { 32 | name: "indent", 33 | args: args{ 34 | s: "one\ntwo\nthree", 35 | indent: "--", 36 | }, 37 | want: "--one\n--two\n--three", 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | if got := Indent(tt.args.s, tt.args.indent); got != tt.want { 43 | t.Errorf("indent() = %q, want %q", got, tt.want) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/text/truncate.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/muesli/reflow/ansi" 7 | "github.com/muesli/reflow/truncate" 8 | ) 9 | 10 | const ( 11 | ellipsis = "..." 12 | minWidthForEllipsis = len(ellipsis) + 2 13 | ) 14 | 15 | // DisplayWidth calculates what the rendered width of a string may be 16 | func DisplayWidth(s string) int { 17 | return ansi.PrintableRuneWidth(s) 18 | } 19 | 20 | // Truncate shortens a string to fit the maximum display width 21 | func Truncate(maxWidth int, s string) string { 22 | w := DisplayWidth(s) 23 | if w <= maxWidth { 24 | return s 25 | } 26 | 27 | tail := "" 28 | if maxWidth >= minWidthForEllipsis { 29 | tail = ellipsis 30 | } 31 | 32 | // Guard against overflow 33 | if maxWidth < 0 { 34 | maxWidth = 0 35 | } 36 | 37 | // Seems to be a false positive from gosec 38 | // since max(uint) > max(int) && maxWidth >= 0 at this point 39 | // nolint:gosec 40 | r := truncate.StringWithTail(s, uint(maxWidth), tail) 41 | if DisplayWidth(r) < maxWidth { 42 | r += " " 43 | } 44 | 45 | return r 46 | } 47 | 48 | // TruncateColumn replaces the first new line character with an ellipsis 49 | // and shortens a string to fit the maximum display width 50 | func TruncateColumn(maxWidth int, s string) string { 51 | if i := strings.IndexAny(s, "\r\n"); i >= 0 { 52 | s = s[:i] + ellipsis 53 | } 54 | return Truncate(maxWidth, s) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/utils/terminal.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mattn/go-isatty" 8 | "golang.org/x/term" 9 | ) 10 | 11 | var IsTerminal = func(f *os.File) bool { 12 | return isatty.IsTerminal(f.Fd()) || IsCygwinTerminal(f) 13 | } 14 | 15 | func IsCygwinTerminal(f *os.File) bool { 16 | return isatty.IsCygwinTerminal(f.Fd()) 17 | } 18 | 19 | var TerminalSize = func(w interface{}) (int, int, error) { 20 | if f, isFile := w.(*os.File); isFile { 21 | return term.GetSize(int(f.Fd())) 22 | } 23 | 24 | return 0, 0, fmt.Errorf("%v is not a file", w) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // Pluralize returns the plural form of a given string 12 | func Pluralize(num int, thing string) string { 13 | if num <= 1 { 14 | return fmt.Sprintf("%d %s", num, thing) 15 | } 16 | return fmt.Sprintf("%d %ss", num, thing) 17 | } 18 | 19 | // MakePath creates a path if it doesn't exist 20 | func MakePath(path string) error { 21 | dir := filepath.Dir(path) 22 | 23 | if _, err := os.Stat(dir); os.IsNotExist(err) { 24 | err = os.MkdirAll(dir, os.ModePerm) 25 | if err != nil { 26 | return err 27 | } 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // Contains check if a slice contains a given string 34 | func Contains(s []string, e string) bool { 35 | for _, a := range s { 36 | if a == e { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | // Differences return the elements in `a` that aren't in `b` 44 | func Differences(a, b []string) []string { 45 | mb := make(map[string]bool) 46 | for _, x := range b { 47 | mb[x] = true 48 | } 49 | var diff []string 50 | for _, x := range a { 51 | if _, ok := mb[x]; !ok { 52 | diff = append(diff, x) 53 | } 54 | } 55 | return diff 56 | } 57 | 58 | // ToKebabCase converts a string to kebab case 59 | func ToKebabCase(str string) string { 60 | matchFirstCap := regexp.MustCompile("(.)([A-Z][a-z]+)") 61 | matchAllCap := regexp.MustCompile("([a-z0-9])([A-Z])") 62 | 63 | snake := matchFirstCap.ReplaceAllString(str, "${1}-${2}") 64 | snake = matchAllCap.ReplaceAllString(snake, "${1}-${2}") 65 | 66 | return strings.ToLower(snake) 67 | } 68 | 69 | // Convert comma separated string values to slice 70 | func StringToSlice(str string) []string { 71 | return strings.Split(strings.ReplaceAll(str, " ", ""), ",") 72 | } 73 | 74 | // Convert slice of string to comma separated string 75 | func SliceToString(str []string) string { 76 | return strings.Join(str, ", ") 77 | } 78 | 79 | // based on https://github.com/watson/ci-info/blob/HEAD/index.js 80 | func IsCI() bool { 81 | return os.Getenv( 82 | "CI", 83 | ) != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari 84 | os.Getenv("CONTINUOUS_INTEGRATION") != "" || // Travis CI, Cirrus CI 85 | os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity 86 | os.Getenv("CI_APP_ID") != "" || // Appflow 87 | os.Getenv("CI_BUILD_ID") != "" || // Appflow 88 | os.Getenv("CI_BUILD_NUMBER") != "" || // Appflow 89 | os.Getenv("RUN_ID") != "" // TaskCluster, dsari 90 | } 91 | 92 | // Convert slice of string to a readable string 93 | // eg: ["one", "two", "three"] -> "one, two and three" 94 | func SliceToReadableString(str []string) string { 95 | if len(str) == 0 { 96 | return "" 97 | } 98 | if len(str) == 1 { 99 | return str[0] 100 | } 101 | if len(str) == 2 { 102 | return fmt.Sprintf("%s and %s", str[0], str[1]) 103 | } 104 | readableStr := "" 105 | if len(str) > 2 { 106 | return fmt.Sprintf("%s%s", 107 | strings.Join(str[:len(str)-1], ", "), 108 | fmt.Sprintf(" and %s", str[len(str)-1])) 109 | } 110 | 111 | return readableStr 112 | } 113 | -------------------------------------------------------------------------------- /pkg/validators/cmd.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | cmdutil "github.com/algolia/cli/pkg/cmdutil" 9 | ) 10 | 11 | // ExactArgs is a validator for commands to print an error with a custom message 12 | // followed by usage, flags and available commands when too few/much arguments are provided 13 | func ExactArgsWithMsg(n int, msg string) cobra.PositionalArgs { 14 | return func(cmd *cobra.Command, args []string) error { 15 | if len(args) != n { 16 | return cmdutil.FlagErrorf("%s", msg) 17 | } 18 | 19 | return nil 20 | } 21 | } 22 | 23 | // NoArgs is a validator for commands to print an error when an argument is provided 24 | func NoArgs() cobra.PositionalArgs { 25 | return func(cmd *cobra.Command, args []string) error { 26 | extractArgs := ExactArgsWithMsg(0, fmt.Sprintf( 27 | "`%s` does not take any positional arguments.", 28 | cmd.CommandPath(), 29 | )) 30 | 31 | return extractArgs(cmd, args) 32 | } 33 | } 34 | 35 | // ExactArgs is the same as ExactArgsWithMsg but displays 36 | // a default error message 37 | func ExactArgs(n int) cobra.PositionalArgs { 38 | argument := "argument" 39 | if n > 1 { 40 | argument = argument + "s" 41 | } 42 | 43 | return func(cmd *cobra.Command, args []string) error { 44 | extractArgs := ExactArgsWithMsg(n, fmt.Sprintf("`%s` requires exactly %d %s.", 45 | cmd.CommandPath(), 46 | n, 47 | argument, 48 | )) 49 | 50 | return extractArgs(cmd, args) 51 | } 52 | } 53 | 54 | // AtLeastNArgs is a validator for commands to print an error with a custom message 55 | // followed by usage, flags and available commands when too few argument(s) are provided 56 | func AtLeastNArgs(n int) cobra.PositionalArgs { 57 | argument := "argument" 58 | if n > 1 { 59 | argument = argument + "s" 60 | } 61 | 62 | return func(cmd *cobra.Command, args []string) error { 63 | if len(args) < n { 64 | msg := fmt.Sprintf("`%s` requires at least %d %s.", cmd.CommandPath(), n, argument) 65 | 66 | return cmdutil.FlagErrorf("%s", msg) 67 | } 68 | 69 | return nil 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/validators/validate.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/algolia/cli/pkg/config" 8 | ) 9 | 10 | // ProfileNameExists validates that a string is a valid profile name. 11 | func ProfileNameExists(cfg config.IConfig) func(profileName interface{}) error { 12 | return func(profileName interface{}) error { 13 | if cfg.ProfileExists(profileName.(string)) { 14 | return fmt.Errorf("profile '%s' already exists", profileName) 15 | } 16 | return nil 17 | } 18 | } 19 | 20 | // ApplicationIDExists validates that a string is a valid Application ID. 21 | func ApplicationIDExists(cfg config.IConfig) func(appID interface{}) error { 22 | return func(appID interface{}) error { 23 | appIDExists, profile := cfg.ApplicationIDExists(appID.(string)) 24 | if appIDExists { 25 | return fmt.Errorf("application ID '%s' already exists in profile '%s'", appID, profile) 26 | } 27 | return nil 28 | } 29 | } 30 | 31 | // PathExists validates that a string is a path that exists. 32 | func PathExists(input string) error { 33 | if _, err := os.Stat(input); os.IsNotExist(err) { 34 | return fmt.Errorf("the provided path %s does not exist", input) 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Version of the CLI. 8 | // This is set to the actual version by GoReleaser, identify by the 9 | // git tag assigned to the release. Versions built from source will 10 | // always show main. 11 | var Version = "main" 12 | 13 | // Template for the version string. 14 | var Template = fmt.Sprintf("algolia version %s\n", Version) 15 | -------------------------------------------------------------------------------- /scripts/completions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rm -rf completions 4 | mkdir completions 5 | for sh in bash zsh fish; do 6 | go run cmd/algolia/main.go completion "$sh" >"completions/algolia.$sh" 7 | done -------------------------------------------------------------------------------- /test/config.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "github.com/algolia/cli/pkg/config" 5 | ) 6 | 7 | type ConfigStub struct { 8 | CurrentProfile config.Profile 9 | profiles []*config.Profile 10 | } 11 | 12 | func (c *ConfigStub) InitConfig() {} 13 | 14 | func (c *ConfigStub) Profile() *config.Profile { 15 | return &c.CurrentProfile 16 | } 17 | 18 | func (c *ConfigStub) Default() *config.Profile { 19 | return &c.CurrentProfile 20 | } 21 | 22 | func (c *ConfigStub) ConfiguredProfiles() []*config.Profile { 23 | return c.profiles 24 | } 25 | 26 | func (c *ConfigStub) ProfileNames() []string { 27 | names := make([]string, 0, len(c.ConfiguredProfiles())) 28 | for _, profile := range c.ConfiguredProfiles() { 29 | names = append(names, profile.Name) 30 | } 31 | return names 32 | } 33 | 34 | func (c *ConfigStub) ProfileExists(name string) bool { 35 | for _, profile := range c.ConfiguredProfiles() { 36 | if profile.Name == name { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func (c *ConfigStub) ApplicationIDExists(appID string) (bool, string) { 44 | for _, profile := range c.ConfiguredProfiles() { 45 | if profile.ApplicationID == appID { 46 | return true, profile.Name 47 | } 48 | } 49 | return false, "" 50 | } 51 | 52 | func (c *ConfigStub) RemoveProfile(name string) error { 53 | for i, profile := range c.ConfiguredProfiles() { 54 | if profile.Name == name { 55 | c.profiles = append(c.profiles[:i], c.profiles[i+1:]...) 56 | return nil 57 | } 58 | } 59 | return nil 60 | } 61 | 62 | func (c *ConfigStub) SetDefaultProfile(name string) error { 63 | for _, profile := range c.ConfiguredProfiles() { 64 | if profile.Name == name { 65 | profile.Default = true 66 | } else { 67 | profile.Default = false 68 | } 69 | } 70 | return nil 71 | } 72 | 73 | func NewConfigStubWithProfiles(p []*config.Profile) *ConfigStub { 74 | return &ConfigStub{ 75 | CurrentProfile: *p[0], 76 | profiles: p, 77 | } 78 | } 79 | 80 | func NewDefaultConfigStub() *ConfigStub { 81 | return NewConfigStubWithProfiles([]*config.Profile{ 82 | { 83 | Name: "default", 84 | ApplicationID: "default", 85 | AdminAPIKey: "default", 86 | Default: true, 87 | }, 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/google/shlex" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/algolia/algoliasearch-client-go/v4/algolia/search" 12 | "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" 13 | "github.com/algolia/cli/api/crawler" 14 | "github.com/algolia/cli/pkg/cmdutil" 15 | "github.com/algolia/cli/pkg/config" 16 | "github.com/algolia/cli/pkg/httpmock" 17 | "github.com/algolia/cli/pkg/iostreams" 18 | ) 19 | 20 | type CmdInOut struct { 21 | InBuf *bytes.Buffer 22 | OutBuf *bytes.Buffer 23 | ErrBuf *bytes.Buffer 24 | } 25 | 26 | func (c CmdInOut) String() string { 27 | return c.OutBuf.String() 28 | } 29 | 30 | func (c CmdInOut) Stderr() string { 31 | return c.ErrBuf.String() 32 | } 33 | 34 | type OutputStub struct { 35 | Out []byte 36 | Error error 37 | } 38 | 39 | func (s OutputStub) Output() ([]byte, error) { 40 | if s.Error != nil { 41 | return s.Out, s.Error 42 | } 43 | return s.Out, nil 44 | } 45 | 46 | func (s OutputStub) Run() error { 47 | if s.Error != nil { 48 | return s.Error 49 | } 50 | return nil 51 | } 52 | 53 | func NewFactory( 54 | isTTY bool, 55 | r *httpmock.Registry, 56 | cfg config.IConfig, 57 | in string, 58 | ) (*cmdutil.Factory, *CmdInOut) { 59 | io, stdin, stdout, stderr := iostreams.Test() 60 | io.SetStdoutTTY(isTTY) 61 | io.SetStdinTTY(isTTY) 62 | io.SetStderrTTY(isTTY) 63 | 64 | if in != "" { 65 | stdin.WriteString(in) 66 | } 67 | 68 | f := &cmdutil.Factory{ 69 | IOStreams: io, 70 | } 71 | 72 | if r != nil { 73 | f.SearchClient = func() (*search.APIClient, error) { 74 | cfg := search.SearchConfiguration{ 75 | Configuration: transport.Configuration{ 76 | AppID: "default", 77 | ApiKey: "default", 78 | Requester: r, 79 | }, 80 | } 81 | return search.NewClientWithConfig(cfg) 82 | } 83 | f.CrawlerClient = func() (*crawler.Client, error) { 84 | return crawler.NewClientWithHTTPClient("id", "key", &http.Client{ 85 | Transport: r, 86 | }), nil 87 | } 88 | } 89 | 90 | if cfg != nil { 91 | f.Config = cfg 92 | } else { 93 | f.Config = &config.Config{} 94 | } 95 | 96 | return f, &CmdInOut{ 97 | InBuf: stdin, 98 | OutBuf: stdout, 99 | ErrBuf: stderr, 100 | } 101 | } 102 | 103 | func Execute(cmd *cobra.Command, cli string, inOut *CmdInOut) (*CmdInOut, error) { 104 | argv, err := shlex.Split(cli) 105 | if err != nil { 106 | return nil, err 107 | } 108 | cmd.SetArgs(argv) 109 | 110 | if inOut.InBuf != nil { 111 | cmd.SetIn(inOut.InBuf) 112 | } else { 113 | cmd.SetIn(&bytes.Buffer{}) 114 | } 115 | cmd.SetOut(io.Discard) 116 | cmd.SetErr(io.Discard) 117 | 118 | _, err = cmd.ExecuteC() 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | return inOut, nil 124 | } 125 | --------------------------------------------------------------------------------