diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index edda880..de1f7fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [master]
+ branches: [master, v3]
pull_request:
- branches: [master]
+ branches: [master, v3]
jobs:
tests:
@@ -12,12 +12,12 @@ jobs:
steps:
- name: Set up Go 1.x
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v5
with:
- go-version: ~1.17
+ go-version: ~1.23
id: go
- name: Check out code into the Go module directory
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Test
run: go test -timeout 1m ./...
@@ -25,11 +25,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-go@v3
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
with:
- go-version: ~1.17
+ go-version: ~1.23
- name: golangci-lint
- uses: golangci/golangci-lint-action@v3
+ uses: golangci/golangci-lint-action@v8
with:
- version: v1.33
+ version: v2.5.0
diff --git a/.github/workflows/tags.yml b/.github/workflows/tags.yml
index dc6ea02..23734b3 100644
--- a/.github/workflows/tags.yml
+++ b/.github/workflows/tags.yml
@@ -9,31 +9,18 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Install Go
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v5
with:
- go-version: ~1.17
+ go-version: ~1.23
- name: Create release
id: goreleaser
- uses: goreleaser/goreleaser-action@v3
+ uses: goreleaser/goreleaser-action@v6
with:
version: latest
- args: release --rm-dist
+ args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Update install links
- run: |
- wget -q https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64 -O jq
- chmod +x ./jq
- tag=$(echo '${{steps.goreleaser.outputs.metadata}}' | ./jq --raw-output '.tag')
- linux_name=$(echo '${{steps.goreleaser.outputs.artifacts}}' | ./jq --raw-output '.[] | select((.goos=="linux") and (.type=="Archive")) | .name')
- mac_name=$(echo '${{steps.goreleaser.outputs.artifacts}}' | ./jq --raw-output '.[] | select((.goos=="darwin") and (.type=="Archive")) | .name')
- win_name=$(echo '${{steps.goreleaser.outputs.artifacts}}' | ./jq --raw-output '.[] | select((.goos=="windows") and (.type=="Archive")) | .name')
- download_url_prefix="https://github.com/${{github.repository}}/releases/download/${tag}"
- short_url_api_prefix="https://go.enapter.com/rest/v3/short-urls"
- curl -q -H "X-Api-Key: ${{secrets.ENAPTER_SHLINK_API_KEY}}" -H "Content-Type: application/json" -X PATCH ${short_url_api_prefix}/enaptercli-linux-install -d "{\"longUrl\":\"${download_url_prefix}/${linux_name}\"}"
- curl -q -H "X-Api-Key: ${{secrets.ENAPTER_SHLINK_API_KEY}}" -H "Content-Type: application/json" -X PATCH ${short_url_api_prefix}/enaptercli-macos-install -d "{\"longUrl\":\"${download_url_prefix}/${mac_name}\"}"
- curl -q -H "X-Api-Key: ${{secrets.ENAPTER_SHLINK_API_KEY}}" -H "Content-Type: application/json" -X PATCH ${short_url_api_prefix}/enaptercli-windows-install -d "{\"longUrl\":\"${download_url_prefix}/${win_name}\"}"
+ TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c8ac242..69518d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.vscode
-^enapter$
+enapter3
dist
-.DS_Store
\ No newline at end of file
+.DS_Store
+.env
diff --git a/.golangci.yml b/.golangci.yml
index 65ed0c1..f6ec344 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,22 +1,16 @@
-run:
- timeout: 5m
-
+version: "2"
linters:
- disable-all: true
+ default: none
enable:
- asciicheck
- bodyclose
- - deadcode
- - depguard
+ - copyloopvar
- dogsled
- - dupl
+ - err113
- errcheck
- errorlint
- exhaustive
- # - exhaustivestruct
- - exportloopref
- funlen
- - gci
- gochecknoglobals
- gochecknoinits
- gocognit
@@ -24,71 +18,72 @@ linters:
- gocritic
- gocyclo
- godot
- # - godox
- - goerr113
- - gofmt
- - gofumpt
- goheader
- - goimports
- - golint
- - gomnd
- gomodguard
- goprintffuncname
- gosec
- - gosimple
- govet
- ineffassign
- # - interfacer # is prone to bad suggestions (officialy deprecated)
- lll
- - maligned
- misspell
+ - mnd
- nakedret
- nestif
- # - nlreturn
- noctx
- nolintlint
- prealloc
+ - revive
- rowserrcheck
- - scopelint
- sqlclosecheck
- staticcheck
- - structcheck
- - stylecheck
- testpackage
- tparallel
- - typecheck
- unconvert
- unparam
- unused
- - varcheck
- whitespace
- # - wrapcheck
- # - wsl
-
-linters-settings:
- lll:
- line-length: 110
- gci:
- local-prefixes: github.com/enapter/enapter-cli
-
-issues:
- exclude-rules:
- # Exclude gosec from running on tests files because this makes no sense.
- - path: _test\.go
- linters:
- - gosec
-
- # Exclude lll issues for long lines with go:generate.
- - linters:
- - lll
- source: "^//go:generate "
-
- # Import paths can be long.
- - linters:
- - lll
- source: "^import "
-
- # Links to articles can be long.
- - linters:
- - lll
- source: "//.*(http|https)://"
+ settings:
+ lll:
+ line-length: 110
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ rules:
+ - linters:
+ - gosec
+ path: _test\.go
+ - linters:
+ - lll
+ source: '^//go:generate '
+ - linters:
+ - lll
+ source: '^import '
+ - linters:
+ - lll
+ source: //.*(http|https)://
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - gofumpt
+ - goimports
+ settings:
+ gci:
+ sections:
+ - standard
+ - default
+ - prefix(github.com/enapter/enapter-cli/)
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/.goreleaser.yml b/.goreleaser.yml
index be29afc..a12d9cb 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,26 +1,59 @@
----
-project_name: enapter-cli
+version: 2
-release:
- github:
- owner: enapter
- name: enapter-cli
+project_name: enapter-cli
builds:
- - binary: enapter
+ - binary: enapter3
+ main: ./cmd/enapter/
+ ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}}
+ env:
+ - CGO_ENABLED=0
goos:
- - darwin
- - windows
- linux
+ - windows
+ - darwin
goarch:
- amd64
- env:
- - CGO_ENABLED=0
- main: ./cmd/enapter/
- ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}}
+ - arm64
+ ignore:
+ - goos: linux
+ goarch: arm64
+ - goos: windows
+ goarch: arm64
+
+release:
+ github:
+ owner: enapter
+ name: enapter-cli
+
+archives:
+ - formats: ['tar.gz']
+ wrap_in_directory: true
+ format_overrides:
+ - goos: windows
+ formats: ['zip']
+ name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}'
checksum:
name_template: '{{ .ProjectName }}-{{ .Version }}-checksums.txt'
+snapshot:
+ version_template: 'SNAPSHOT-{{ .Tag }}'
+
changelog:
- skip: true
+ disable: true
+
+brews:
+ - repository:
+ owner: enapter
+ name: homebrew-tap
+ token: "{{ .Env.TAP_GITHUB_TOKEN }}"
+ name: enapter@3
+ directory: Formula
+ homepage: https://github.com/Enapter/enapter-cli
+ description: Command-line tool for Enapter Energy Management System Toolkit
+
+ install: |
+ bin.install "enapter3"
+ test: |
+ assert_match "Enapter CLI #{version}", shell_output("#{bin}/enapter3 --version")
diff --git a/README.md b/README.md
index 4678657..5fc04fd 100644
--- a/README.md
+++ b/README.md
@@ -4,18 +4,30 @@
[](https://github.com/enapter/enapter-cli/releases/latest)
-This tool helps Enapter customers to work with devices. It useful in the following cases:
-1. Develop devices via blueprints.
-2. Update and monitor devices.
+This tool helps Enapter customers to work with devices it is alternative for [Enapter IDE for EMS Toolkit 3.0](https://marketplace.visualstudio.com/items?itemName=Enapter.enapter-ems-toolkit-ide).
+It helpful in the following cases:
+
+1. Managing all your EMS setup as a code with Git and Ansible / Puppet
+2. Establishing CI/CD workflow
+3. Development and debugging of Enapter Blueprints
+4. Development and debugging of Enapter Gateway Rules
## How to install
### macOS - recommended
+Version 1:
+
```bash
brew tap enapter/tap && brew install enapter
```
+Version 3:
+
+```bash
+brew tap enapter/tap && brew install enapter@3
+```
+
### Get prebuilt binaries
Choose your platform and required release on the [Releases page](https://github.com/Enapter/enapter-cli/releases).
@@ -32,7 +44,10 @@ Also you can pass custom output path:
./build.sh /usr/local/bin/enapter
```
-## How to use
+## How to use Version 1:
+
+> [!NOTE]
+> Version 1 works only with Enapter Cloud connection.
### API token
@@ -44,16 +59,43 @@ Enapter CLI requires access token for authentication. Obtaining of the token is
4. Follow the instructions on the screen
-5. Set environment variable `ENAPTER_API_TOKEN` with new token. To make it permanent don't forget to add it to configuration files of your shell.
+5. Set environment variable `ENAPTER3_API_TOKEN` with new token. To make it permanent don't forget to add it to configuration files of your shell.
```bash
- export ENAPTER_API_TOKEN="your token"
+ export ENAPTER3_API_TOKEN="your token"
```
Please note that if you don't save your token, it is not possible to reveal it anymore. You need generate new token.
+## How to use Version 3:
+
+### API token
+
+Enapter CLI requires access token for authentication. Obtaining of the token is easy and can be done by following few steps.
+
+1. Navigate to your Enapter Gateway 3.0 Web Interface `Settings` page by using IP address or mDNS name [http://enapter-gateway.local/settings](https://enapter-gateway.local/settings)
+2. Enapter your Enapter Gateway password
+3. Click `API Token` and copy token to clipboard
+4. Set environment variables `ENAPTER3_API_TOKEN`, `ENAPTER3_API_URL` and `ENAPTER3_API_ALLOW_INSECURE`. To make it permanent don't forget to add it to configuration files of your shell.
+
+ ```bash
+ export ENAPTER3_API_TOKEN="your token"
+ export ENAPTER3_API_URL="http://ip_address/api"
+ export ENAPTER3_API_ALLOW_INSECURE=true
+ ```
+
+5. Check connection works by running
+
+ ```bash
+ enapter3 device list
+ ```
+
### Autocompletion in your favourite terminal app
-In order to make life easier with command line interface, you may use [Fig - the next-generation command line](https://fig.io/). This autocompletion tool has native support for the Enapter CLI for Mac OS X and Linux.
+> [!NOTE]
+> Available for Version 1 now.
+
+In order to make life easier with command line interface, you may use [Amazon Q](https://aws.amazon.com/q/). This autocompletion tool has native support for the Enapter CLI for Mac OS X and Linux.
+
+
-
\ No newline at end of file
diff --git a/build.sh b/build.sh
index 4b7a413..74fadde 100755
--- a/build.sh
+++ b/build.sh
@@ -2,7 +2,7 @@
set -ex
-output=${1:-enapter}
+output=${1:-enapter3}
BUILD_VERSION=$(git describe --tag 2> /dev/null)
BUILD_COMMIT=$(git rev-parse --short HEAD)
diff --git a/go.mod b/go.mod
index 865d5be..215c2f5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,21 +1,20 @@
module github.com/enapter/enapter-cli
-go 1.19
+go 1.24.0
require (
- github.com/bxcodec/faker/v3 v3.5.0
- github.com/gorilla/websocket v1.4.2
- github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a
- github.com/stretchr/testify v1.6.1
- github.com/urfave/cli/v2 v2.3.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/stretchr/testify v1.9.0
+ github.com/urfave/cli/v2 v2.27.4
+ golang.org/x/term v0.37.0
)
require (
- github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
- github.com/davecgh/go-spew v1.1.0 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/russross/blackfriday/v2 v2.0.1 // indirect
- github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
- golang.org/x/net v0.17.0 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
+ golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index abef50e..2e6ee48 100644
--- a/go.sum
+++ b/go.sum
@@ -1,30 +1,24 @@
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/bxcodec/faker/v3 v3.5.0 h1:Rahy6dwbd6up0wbwbV7dFyQb+jmdC51kpATuUdnzfMg=
-github.com/bxcodec/faker/v3 v3.5.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
+github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
-github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
-github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
-github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
-golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
+github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
+github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
+github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/app/cliflags/duration.go b/internal/app/cliflags/duration.go
new file mode 100644
index 0000000..cd68e37
--- /dev/null
+++ b/internal/app/cliflags/duration.go
@@ -0,0 +1,27 @@
+package cliflags
+
+import (
+ "github.com/urfave/cli/v2"
+)
+
+// Duration is a wrapper around cli.DurationFlag to implement cli.Flag interface.
+// It differs from cli.DurationFlag in that it does not return a default text if the value is zero.
+type Duration struct {
+ cli.DurationFlag
+}
+
+var (
+ _ cli.Flag = (*Duration)(nil)
+ _ cli.DocGenerationFlag = (*Duration)(nil)
+)
+
+func (d *Duration) String() string {
+ return cli.FlagStringer(d)
+}
+
+func (d *Duration) GetDefaultText() string {
+ if d.Value == 0 {
+ return ""
+ }
+ return d.DurationFlag.GetDefaultText()
+}
diff --git a/internal/app/configfile/config.go b/internal/app/configfile/config.go
new file mode 100644
index 0000000..e479e8f
--- /dev/null
+++ b/internal/app/configfile/config.go
@@ -0,0 +1,94 @@
+package configfile
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+type Config struct {
+ DefaultConn string `json:"default_connection,omitempty"`
+ Connections map[string]Connection `json:"connections,omitempty"`
+}
+
+type Connection struct {
+ Gateway bool `json:"gateway,omitempty"`
+ URL string `json:"url"`
+ SiteID string `json:"site_id,omitempty"`
+ Token Token `json:"token"`
+ AllowInsecure bool `json:"allow_insecure,omitempty"`
+}
+
+type Token struct {
+ Value string `json:"value"`
+}
+
+const (
+ dirName = ".enapter3"
+ fileName = "config.json"
+)
+
+func Load() (Config, error) {
+ dir, err := configDir()
+ if err != nil {
+ return Config{}, err
+ }
+
+ path := filepath.Join(dir, fileName)
+ f, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return Config{}, nil
+ }
+ return Config{}, fmt.Errorf("open config file: %w", err)
+ }
+ defer f.Close()
+
+ var config Config
+ if err := json.NewDecoder(f).Decode(&config); err != nil {
+ return Config{}, fmt.Errorf("decode config file: %w", err)
+ }
+
+ return config, nil
+}
+
+func Save(c Config) error {
+ dir, err := configDir()
+ if err != nil {
+ return err
+ }
+
+ const perm = 0o755
+ if err := os.MkdirAll(dir, perm); err != nil {
+ return fmt.Errorf("create config dir: %w", err)
+ }
+
+ path := filepath.Join(dir, fileName)
+ f, err := os.Create(path)
+ if err != nil {
+ return fmt.Errorf("create config file: %w", err)
+ }
+ defer f.Close()
+
+ encoder := json.NewEncoder(f)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(c); err != nil {
+ return fmt.Errorf("encode config file: %w", err)
+ }
+
+ return f.Sync()
+}
+
+func configDir() (string, error) {
+ if p := os.Getenv("ENAPTER3_CONFIG"); p != "" {
+ return p, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("get home dir: %w", err)
+ }
+ return filepath.Join(home, dirName), nil
+}
diff --git a/internal/app/enaptercli/app_test.go b/internal/app/enaptercli/app_test.go
index 2b066a6..271ad05 100644
--- a/internal/app/enaptercli/app_test.go
+++ b/internal/app/enaptercli/app_test.go
@@ -19,19 +19,17 @@ var errExitTimeout = errors.New("exit timed out")
type testApp struct {
app *cli.App
outBuf *lineBuffer
- errBuf *bytes.Buffer
errCh chan error
cancel func()
}
func startTestApp(args ...string) *testApp {
outBuf := newLineBuffer()
- errBuf := &bytes.Buffer{}
app := enaptercli.NewApp()
app.HideVersion = true
app.Writer = outBuf
- app.ErrWriter = errBuf
+ app.ErrWriter = outBuf
app.ExitErrHandler = func(*cli.Context, error) {}
errCh := make(chan error, 1)
@@ -43,7 +41,6 @@ func startTestApp(args ...string) *testApp {
return &testApp{
app: app,
outBuf: outBuf,
- errBuf: errBuf,
errCh: errCh,
cancel: cancel,
}
@@ -63,7 +60,7 @@ func (a *testApp) Wait() error {
}
}
-func (a *testApp) Stdout() *lineBuffer {
+func (a *testApp) Output() *lineBuffer {
return a.outBuf
}
diff --git a/internal/app/enaptercli/cmd_base.go b/internal/app/enaptercli/cmd_base.go
index e941a90..a111cee 100644
--- a/internal/app/enaptercli/cmd_base.go
+++ b/internal/app/enaptercli/cmd_base.go
@@ -1,66 +1,440 @@
package enaptercli
import (
+ "bytes"
+ "cmp"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
"io"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+ "github.com/gorilla/websocket"
"github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/configfile"
)
+const defaultURL = "https://api.enapter.com"
+
type cmdBase struct {
- token string
- apiHost string
- graphqlURL string
- websocketsURL string
- writer io.Writer
+ connName string
+ token string
+ apiURL string
+ siteID string
+ apiAllowInsecure bool
+ verbose bool
+ userAgent string
+ writer io.Writer
+ errWriter io.Writer
+ httpClient *http.Client
}
func (c *cmdBase) Flags() []cli.Flag {
return []cli.Flag{
+ &cli.StringFlag{
+ Name: "connection",
+ Usage: "name of the connection to use",
+ Aliases: []string{"c"},
+ Destination: &c.connName,
+ },
&cli.StringFlag{
Name: "token",
Usage: "Enapter API token",
- EnvVars: []string{"ENAPTER_API_TOKEN"},
+ EnvVars: []string{"ENAPTER3_API_TOKEN"},
Hidden: true,
Destination: &c.token,
},
&cli.StringFlag{
- Name: "api-host",
- Usage: "Override API endpoint",
- EnvVars: []string{"ENAPTER_API_HOST"},
+ Name: "api-url",
+ Usage: "override API base URL",
+ EnvVars: []string{"ENAPTER3_API_URL"},
+ Value: defaultURL,
Hidden: true,
- Value: "https://api.enapter.com",
- Destination: &c.apiHost,
+ Destination: &c.apiURL,
+ Action: func(_ *cli.Context, v string) error {
+ c.apiURL = strings.TrimSuffix(v, "/")
+ return nil
+ },
},
- &cli.StringFlag{
- Name: "gql-api-url",
- Usage: "Override Cloud API endpoint",
- EnvVars: []string{"ENAPTER_GQL_API_URL"},
- Hidden: true,
- Value: "https://cli.enapter.com/graphql",
- Destination: &c.graphqlURL,
+ &cli.BoolFlag{
+ Name: "api-allow-insecure",
+ Usage: "allow insecure connections to the Enapter API",
+ EnvVars: []string{"ENAPTER3_API_ALLOW_INSECURE"},
+ Destination: &c.apiAllowInsecure,
},
- &cli.StringFlag{
- Name: "ws-api-url",
- Usage: "Override Cloud API endpoint",
- EnvVars: []string{"ENAPTER_WS_API_URL"},
- Hidden: true,
- Value: "wss://cli.enapter.com/cable",
- Destination: &c.websocketsURL,
+ &cli.BoolFlag{
+ Name: "verbose",
+ Usage: "log extra details about the operation",
+ Destination: &c.verbose,
},
}
}
func (c *cmdBase) Before(cliCtx *cli.Context) error {
- if cliCtx.String("token") == "" {
- return errAPITokenMissed
+ if err := c.setupCredentials(cliCtx); err != nil {
+ return err
}
+
+ c.userAgent = "enapter-cli/" + cliCtx.App.Version
c.writer = cliCtx.App.Writer
+ c.errWriter = cliCtx.App.ErrWriter
+ c.httpClient = &http.Client{
+ Transport: &http.Transport{
+ //nolint:gosec // This is needed to allow self-signed certificates on Gateway.
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: c.apiAllowInsecure},
+ },
+ }
+
return nil
}
-func (c *cmdBase) HelpTemplate() string {
- return cli.CommandHelpTemplate + `ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
+func (c *cmdBase) setupCredentials(cliCtx *cli.Context) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ if c.connName != "" {
+ conn, ok := config.Connections[c.connName]
+ if !ok {
+ return cli.Exit("Unknown connection name.", 1)
+ }
+ if cliCtx.IsSet("token") || cliCtx.IsSet("api-url") || cliCtx.IsSet("api-allow-insecure") {
+ fmt.Fprintln(cliCtx.App.ErrWriter,
+ "WARNING: credentials set via environment variables or flags are ignored.")
+ }
+ c.token = conn.Token.Value
+ c.apiURL = conn.URL
+ c.siteID = conn.SiteID
+ c.apiAllowInsecure = conn.AllowInsecure
+ return nil
+ }
+
+ if c.token != "" {
+ return nil
+ }
+
+ if config.DefaultConn != "" {
+ conn, ok := config.Connections[config.DefaultConn]
+ if !ok {
+ return cli.Exit("Default connection is invalid.", 1)
+ }
+ c.token = conn.Token.Value
+ c.apiURL = conn.URL
+ c.siteID = conn.SiteID
+ c.apiAllowInsecure = conn.AllowInsecure
+ return nil
+ }
+
+ return cli.Exit("No connection configured.\n\n"+
+ "Please, specify connection using --connection flag.\n\n"+
+ "To list available connections:\n$ enapter3 connection list\n\n"+
+ "To add a new connection:\n$ enapter3 connection add\n", 1)
+}
+
+const enapterAPIEnvVarsHelp = `
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
`
+
+func (c *cmdBase) CommandHelpTemplate() string {
+ return cli.CommandHelpTemplate + enapterAPIEnvVarsHelp
+}
+
+func (c *cmdBase) SubcommandHelpTemplate() string {
+ return cli.SubcommandHelpTemplate + enapterAPIEnvVarsHelp
+}
+
+func (c *cmdBase) chooseSiteID(cmdSiteID string) (string, error) {
+ if cmdSiteID != "" && c.siteID != "" && c.siteID != cmdSiteID {
+ return "", errSiteIDMismatch
+ }
+ siteID := cmp.Or(cmdSiteID, c.siteID)
+ if siteID == "" {
+ return "", errSiteIDMissing
+ }
+ return siteID, nil
+}
+
+type doHTTPRequestParams struct {
+ Method string
+ Path string
+ Query url.Values
+ Body io.Reader
+ ContentType string
+ RespProcessor func(*http.Response) error
+}
+
+func (c *cmdBase) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ req, err := http.NewRequestWithContext(ctx, p.Method, c.apiURL+"/v3"+p.Path, p.Body)
+ if err != nil {
+ return fmt.Errorf("build http request: %w", err)
+ }
+
+ req.Header.Set("X-Enapter-Auth-Token", c.token)
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Content-Type", p.ContentType)
+ req.URL.RawQuery = p.Query.Encode()
+
+ if c.verbose {
+ bodyStr, err := getRequestBodyString(req, p.ContentType)
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprintf(c.errWriter, "== Do http request %s %s\n", p.Method, req.URL.String())
+ fmt.Fprintf(c.errWriter, "=== Begin body\n%s\n=== End body\n", bodyStr)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ if e := (&tls.CertificateVerificationError{}); errors.As(err, &e) {
+ return fmt.Errorf("do http request: %w (try to use --api-allow-insecure)", err)
+ }
+ return fmt.Errorf("do http request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if p.RespProcessor == nil {
+ return c.defaultRespProcessor(resp)
+ }
+ return p.RespProcessor(resp)
+}
+
+type runWebSocketParams struct {
+ Path string
+ Query url.Values
+ RespProcessor func(io.Reader) error
+}
+
+func (c *cmdBase) runWebSocket(ctx context.Context, p runWebSocketParams) error {
+ url, err := url.Parse(c.apiURL + "/v3" + p.Path)
+ if err != nil {
+ return fmt.Errorf("parse url: %w", err)
+ }
+ url.RawQuery = p.Query.Encode()
+
+ headers := make(http.Header)
+ headers.Set("X-Enapter-Auth-Token", c.token)
+ headers.Set("User-Agent", c.userAgent)
+
+ for retry := false; ; retry = true {
+ if retry {
+ fmt.Fprintln(c.errWriter, "Reconnecting...")
+ time.Sleep(time.Second)
+ }
+
+ conn, err := c.dialWebSocket(ctx, url, headers)
+ if err != nil {
+ if e := cli.ExitCoder(nil); errors.As(err, &e) {
+ return err
+ }
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ fmt.Fprintln(c.errWriter, "Failed to retrieve data:", err)
+ continue
+ }
+ }
+ fmt.Fprintln(c.errWriter, "Connection established")
+
+ closeCh := make(chan struct{})
+ go func() {
+ select {
+ case <-ctx.Done():
+ case <-closeCh:
+ }
+ conn.Close()
+ }()
+
+ if err := c.readWebSocket(conn, p.RespProcessor); err != nil {
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ fmt.Fprintln(c.errWriter, "Failed to retrieve data:", err)
+ close(closeCh)
+ }
+ }
+ }
+}
+
+func (c *cmdBase) defaultRespProcessor(resp *http.Response) error {
+ if resp.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(resp), 1)
+ }
+
+ n, _ := io.Copy(c.writer, resp.Body)
+ if n == 0 {
+ _, _ = io.WriteString(c.writer, "Request finished without body\n")
+ }
+
+ return nil
+}
+
+func (c *cmdBase) dialWebSocket(
+ ctx context.Context, url *url.URL, headers http.Header,
+) (*websocket.Conn, error) {
+ const timeout = 5 * time.Second
+ dialer := websocket.Dialer{
+ HandshakeTimeout: timeout,
+ //nolint:gosec // This is needed to allow self-signed certificates on Gateway.
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: c.apiAllowInsecure},
+ }
+
+ const maxRetries = 2
+ for i := 0; i < maxRetries; i++ {
+ url.Scheme = websocketScheme(url.Scheme)
+
+ if c.verbose {
+ fmt.Fprintf(c.errWriter, "== Dialing WebSocket at %s\n", url.String())
+ }
+
+ //nolint:bodyclose // body should be closed by callers
+ conn, resp, err := dialer.DialContext(ctx, url.String(), headers)
+ if err != nil {
+ if loc, err := redirectLocation(resp); err != nil {
+ return nil, err
+ } else if loc != nil {
+ url = loc
+ continue
+ }
+ if e := (&tls.CertificateVerificationError{}); errors.As(err, &e) {
+ message := fmt.Sprintf("dial: %v (try to use --api-allow-insecure)", err)
+ return nil, cli.Exit(message, 1)
+ }
+ if resp != nil {
+ message := parseRespErrorMessage(resp)
+ return nil, fmt.Errorf("dial: %w: %s", err, message)
+ }
+ return nil, fmt.Errorf("dial: %w", err)
+ }
+
+ return conn, nil
+ }
+
+ return nil, cli.Exit("Too many redirects", 1)
+}
+
+func (c *cmdBase) readWebSocket(
+ conn *websocket.Conn, processor func(io.Reader) error,
+) error {
+ for {
+ _, r, err := conn.NextReader()
+ if err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+ if err := processor(r); err != nil {
+ return err
+ }
+ }
+}
+
+func getRequestBodyString(req *http.Request, contentType string) (string, error) {
+ if req.Body == nil {
+ return "", nil
+ }
+ bb := &bytes.Buffer{}
+ if _, err := io.Copy(bb, req.Body); err != nil {
+ return "", fmt.Errorf("reading body for verbose log: %w", err)
+ }
+ if err := req.Body.Close(); err != nil {
+ return "", fmt.Errorf("closing body for verbose log: %w", err)
+ }
+ req.Body = io.NopCloser(bb)
+
+ if contentType != contentTypeJSON {
+ return base64.RawStdEncoding.EncodeToString(bb.Bytes()), nil
+ }
+
+ return bb.String(), nil
+}
+
+func okRespBodyProcessor(fn func(body io.Reader) error) func(resp *http.Response) error {
+ return func(resp *http.Response) error {
+ if resp.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(resp), 1)
+ }
+ return fn(resp.Body)
+ }
+}
+
+func parseRespErrorMessage(resp *http.Response) string {
+ var errs struct {
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors"`
+ }
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ if err := json.Unmarshal(bodyBytes, &errs); err != nil {
+ if !errors.Is(err, io.EOF) {
+ return fmt.Sprintf("Request finished with HTTP status %q, but body is not valid JSON error response. "+
+ "Please, check API URL is correct.\n\nReceived body:\n%s\n", resp.Status, bodyBytes)
+ }
+ }
+
+ if len(errs.Errors) > 0 {
+ msg := errs.Errors[0].Message
+ if len(msg) > 0 {
+ return msg
+ }
+ }
+
+ return fmt.Sprintf("Request finished with HTTP status %q, but without error message", resp.Status)
+}
+
+func validateExpandFlag(cliCtx *cli.Context, supportedFields []string) error {
+ for _, field := range cliCtx.StringSlice("expand") {
+ if err := validateFlag("expand", field, supportedFields); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateFlag(context, value string, allowedValues []string) error {
+ slices.Sort(allowedValues)
+ if _, ok := slices.BinarySearch(allowedValues, value); !ok {
+ return fmt.Errorf("%w: %s is not supported for %s, should be one of %s",
+ errUnsupportedFlagValue, value, context, allowedValues)
+ }
+ return nil
+}
+
+func websocketScheme(s string) string {
+ switch s {
+ case "https":
+ return "wss"
+ case "http":
+ return "ws"
+ default:
+ return s
+ }
+}
+
+func redirectLocation(resp *http.Response) (*url.URL, error) {
+ if resp == nil {
+ return nil, nil
+ }
+ if resp.StatusCode != http.StatusPermanentRedirect {
+ return nil, nil
+ }
+ location := resp.Header.Get("Location")
+ url, err := url.Parse(location)
+ if err != nil {
+ return nil, fmt.Errorf("parse location: %w", err)
+ }
+ return url, nil
}
diff --git a/internal/app/enaptercli/cmd_base_pagination.go b/internal/app/enaptercli/cmd_base_pagination.go
new file mode 100644
index 0000000..f8318f7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_base_pagination.go
@@ -0,0 +1,123 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/urfave/cli/v2"
+)
+
+var errEndPagination = errors.New("end pagination")
+
+type paginateHTTPRequestParams struct {
+ BaseParams doHTTPRequestParams
+ DoFn func(ctx context.Context, p doHTTPRequestParams) error
+ Limit int
+ ObjectName string
+}
+
+func (c *cmdBase) doPaginateRequest(ctx context.Context, p paginateHTTPRequestParams) error {
+ const maxPageLimit = 50
+ if p.BaseParams.Query == nil {
+ p.BaseParams.Query = url.Values{}
+ }
+ if p.Limit > 0 && p.Limit < maxPageLimit {
+ p.BaseParams.Query.Set("limit", strconv.Itoa(p.Limit))
+ return p.DoFn(ctx, p.BaseParams)
+ }
+
+ paginateRespProcesor := &paginateRespProcesor{
+ ObjectName: p.ObjectName,
+ seenObjects: make(map[string]struct{}),
+ }
+ for {
+ reqPageParams := p.BaseParams
+ reqPageParams.Query.Set("offset", strconv.Itoa(len(paginateRespProcesor.Objects)))
+ reqPageParams.Query.Set("limit", strconv.Itoa(maxPageLimit))
+ reqPageParams.RespProcessor = paginateRespProcesor.Process
+
+ err := p.DoFn(ctx, reqPageParams)
+ if err != nil {
+ if errors.Is(err, errEndPagination) {
+ break
+ }
+ return fmt.Errorf("failed to retrieve page: %w", err)
+ }
+ if p.Limit > 0 && len(paginateRespProcesor.Objects) >= p.Limit {
+ break
+ }
+ }
+
+ returnCount := len(paginateRespProcesor.Objects)
+ if p.Limit > 0 && returnCount > p.Limit {
+ returnCount = p.Limit
+ }
+ respBytes, err := json.Marshal(map[string]any{
+ "total_count": paginateRespProcesor.TotalCount,
+ p.ObjectName: paginateRespProcesor.Objects[:returnCount],
+ })
+ if err != nil {
+ return cli.Exit("Failed to marshal response: "+err.Error(), 1)
+ }
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(respBytes)),
+ }
+ return c.defaultRespProcessor(resp)
+}
+
+type paginateRespProcesor struct {
+ TotalCount int
+ Objects []any
+ ObjectName string
+ seenObjects map[string]struct{}
+}
+
+func (p *paginateRespProcesor) Process(resp *http.Response) error {
+ if resp.StatusCode != http.StatusOK {
+ return cli.Exit("Unexpected response status: "+resp.Status, 1)
+ }
+
+ var pageBody map[string]json.RawMessage
+ if err := json.NewDecoder(resp.Body).Decode(&pageBody); err != nil {
+ return cli.Exit("Failed to parse response: "+err.Error(), 1)
+ }
+
+ if err := json.Unmarshal(pageBody["total_count"], &p.TotalCount); err != nil {
+ return cli.Exit("Failed to parse total_count: "+err.Error(), 1)
+ }
+
+ var objects []json.RawMessage
+ if err := json.Unmarshal(pageBody[p.ObjectName], &objects); err != nil {
+ return cli.Exit("Failed to parse "+p.ObjectName+": "+err.Error(), 1)
+ }
+
+ if len(objects) == 0 {
+ return errEndPagination
+ }
+
+ for _, obj := range objects {
+ var objMap map[string]any
+ if err := json.Unmarshal(obj, &objMap); err != nil {
+ return cli.Exit("Failed to parse object: "+err.Error(), 1)
+ }
+
+ id, ok := objMap["id"].(string)
+ if !ok || id == "" {
+ return cli.Exit("Object ID is missing or not a string", 1)
+ }
+
+ if _, seen := p.seenObjects[id]; !seen {
+ p.seenObjects[id] = struct{}{}
+ p.Objects = append(p.Objects, objMap)
+ }
+ }
+ return nil
+}
diff --git a/internal/app/enaptercli/cmd_blueprint.go b/internal/app/enaptercli/cmd_blueprint.go
new file mode 100644
index 0000000..62f563a
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint.go
@@ -0,0 +1,59 @@
+package enaptercli
+
+import (
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprint struct {
+ cmdBase
+}
+
+func buildCmdBlueprint() *cli.Command {
+ cmd := &cmdBlueprint{}
+ return &cli.Command{
+ Name: "blueprint",
+ Usage: "Manage blueprints",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdBlueprintProfiles(),
+ buildCmdBlueprintUpload(),
+ buildCmdBlueprintDownload(),
+ buildCmdBlueprintGet(),
+ },
+ }
+}
+
+func isBlueprintID(s string) bool {
+ const blueprintIDLen = 36
+ if len(s) != blueprintIDLen {
+ return false
+ }
+
+ isDashPos := func(i int) bool { return i == 8 || i == 13 || i == 18 || i == 23 }
+ for i := 0; i < blueprintIDLen; i++ {
+ if isDashPos(i) {
+ if s[i] != '-' {
+ return false
+ }
+ } else {
+ isHexDigit := (s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f')
+ if !isHexDigit {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func parseBlueprintName(n string) (name, tag string) {
+ const blueprintNameParts = 2
+ nameTag := strings.SplitN(n, ":", blueprintNameParts)
+ name = nameTag[0]
+ tag = "latest"
+ if len(nameTag) > 1 {
+ tag = nameTag[1]
+ }
+ return name, tag
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_download.go b/internal/app/enaptercli/cmd_blueprint_download.go
new file mode 100644
index 0000000..346d8a1
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_download.go
@@ -0,0 +1,97 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintDownload struct {
+ cmdBlueprint
+ blueprintID string
+ outputFileName string
+}
+
+func buildCmdBlueprintDownload() *cli.Command {
+ cmd := &cmdBlueprintDownload{}
+ return &cli.Command{
+ Name: "download",
+ Usage: "Download the blueprint zip from the Platform",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdBlueprintDownload) Flags() []cli.Flag {
+ flags := c.cmdBlueprint.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "blueprint-id",
+ Aliases: []string{"b"},
+ Usage: "blueprint name or ID to download",
+ Destination: &c.blueprintID,
+ Required: true,
+ }, &cli.StringFlag{
+ Name: "output",
+ Aliases: []string{"o"},
+ Usage: "blueprint file name to save the blueprint",
+ Destination: &c.outputFileName,
+ })
+}
+
+func (c *cmdBlueprintDownload) do(ctx context.Context) error {
+ if c.outputFileName == "" {
+ c.outputFileName = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(c.blueprintID,
+ ":", "_"), ".", "_"), "/", "_") + ".enbp"
+ }
+
+ if !isBlueprintID(c.blueprintID) {
+ blueprintName, blueprintTag := parseBlueprintName(c.blueprintID)
+ err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/blueprints/enapter/" + blueprintName + "/" + blueprintTag,
+ //nolint:bodyclose //body is closed in doHTTPRequest
+ RespProcessor: okRespBodyProcessor(func(body io.Reader) error {
+ var resp struct {
+ Blueprint struct {
+ ID string `json:"id"`
+ } `json:"blueprint"`
+ }
+ if err := json.NewDecoder(body).Decode(&resp); err != nil {
+ return fmt.Errorf("parse response body: %w", err)
+ }
+ c.blueprintID = resp.Blueprint.ID
+ return nil
+ }),
+ })
+ if err != nil {
+ return fmt.Errorf("get blueprint info by name: %w", err)
+ }
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/blueprints/" + c.blueprintID + "/zip",
+ //nolint:bodyclose //body is closed in doHTTPRequest
+ RespProcessor: okRespBodyProcessor(func(body io.Reader) error {
+ outFile, err := os.Create(c.outputFileName)
+ if err != nil {
+ return fmt.Errorf("create output file %q: %w", c.outputFileName, err)
+ }
+ if _, err := io.Copy(outFile, body); err != nil {
+ return fmt.Errorf("write output file %q: %w", c.outputFileName, err)
+ }
+ fmt.Fprintln(c.writer, c.outputFileName)
+ return nil
+ }),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_get.go b/internal/app/enaptercli/cmd_blueprint_get.go
new file mode 100644
index 0000000..09153c0
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_get.go
@@ -0,0 +1,53 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintGet struct {
+ cmdBlueprint
+ blueprintID string
+}
+
+func buildCmdBlueprintGet() *cli.Command {
+ cmd := &cmdBlueprintGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Retrieve blueprint metadata",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.get(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdBlueprintGet) Flags() []cli.Flag {
+ flags := c.cmdBlueprint.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "blueprint-id",
+ Aliases: []string{"b"},
+ Usage: "blueprint name or ID to retrieve",
+ Destination: &c.blueprintID,
+ Required: true,
+ })
+}
+
+func (c *cmdBlueprintGet) get(ctx context.Context) error {
+ if isBlueprintID(c.blueprintID) {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/blueprints/" + c.blueprintID,
+ })
+ }
+
+ blueprintName, blueprintTag := parseBlueprintName(c.blueprintID)
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/blueprints/enapter/" + blueprintName + "/" + blueprintTag,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_profiles.go b/internal/app/enaptercli/cmd_blueprint_profiles.go
new file mode 100644
index 0000000..8976de7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_profiles.go
@@ -0,0 +1,22 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintProfiles struct {
+ cmdBase
+}
+
+func buildCmdBlueprintProfiles() *cli.Command {
+ cmd := &cmdBlueprintProfiles{}
+ return &cli.Command{
+ Name: "profiles",
+ Usage: "Manage blueprint profiles",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdBlueprintProfilesDownload(),
+ buildCmdBlueprintProfilesUpload(),
+ },
+ }
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_profiles_download.go b/internal/app/enaptercli/cmd_blueprint_profiles_download.go
new file mode 100644
index 0000000..130f273
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_profiles_download.go
@@ -0,0 +1,63 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintProfilesDownload struct {
+ cmdBlueprintProfiles
+ outputFileName string
+}
+
+func buildCmdBlueprintProfilesDownload() *cli.Command {
+ cmd := &cmdBlueprintProfilesDownload{}
+ return &cli.Command{
+ Name: "download",
+ Usage: "Download profiles zip from the Platform",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdBlueprintProfilesDownload) Flags() []cli.Flag {
+ flags := c.cmdBlueprintProfiles.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "output",
+ Aliases: []string{"o"},
+ Usage: "file name to save the downloaded profiles",
+ Destination: &c.outputFileName,
+ })
+}
+
+func (c *cmdBlueprintProfilesDownload) do(ctx context.Context) error {
+ if c.outputFileName == "" {
+ c.outputFileName = "profiles.zip"
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/blueprints/download_device_profiles",
+ //nolint:bodyclose //body is closed in doHTTPRequest
+ RespProcessor: okRespBodyProcessor(func(body io.Reader) error {
+ outFile, err := os.Create(c.outputFileName)
+ if err != nil {
+ return fmt.Errorf("create output file %q: %w", c.outputFileName, err)
+ }
+ if _, err := io.Copy(outFile, body); err != nil {
+ return fmt.Errorf("write output file %q: %w", c.outputFileName, err)
+ }
+ fmt.Fprintln(c.writer, c.outputFileName)
+ return nil
+ }),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_profiles_upload.go b/internal/app/enaptercli/cmd_blueprint_profiles_upload.go
new file mode 100644
index 0000000..bafbe19
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_profiles_upload.go
@@ -0,0 +1,54 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintProfilesUpload struct {
+ cmdBlueprintProfiles
+ profilesPath string
+}
+
+func buildCmdBlueprintProfilesUpload() *cli.Command {
+ cmd := &cmdBlueprintProfilesUpload{}
+ return &cli.Command{
+ Name: "upload",
+ Usage: "Upload profiles to the Platform",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.upload(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdBlueprintProfilesUpload) Flags() []cli.Flag {
+ flags := c.cmdBlueprintProfiles.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "path",
+ Aliases: []string{"p"},
+ Usage: "profiles zip file path",
+ Destination: &c.profilesPath,
+ Required: true,
+ })
+}
+
+func (c *cmdBlueprintProfilesUpload) upload(ctx context.Context) error {
+ data, err := os.ReadFile(c.profilesPath)
+ if err != nil {
+ return fmt.Errorf("read zip file: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/blueprints/upload_device_profiles",
+ Body: bytes.NewReader(data),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_blueprint_upload.go b/internal/app/enaptercli/cmd_blueprint_upload.go
new file mode 100644
index 0000000..ae02529
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_upload.go
@@ -0,0 +1,101 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdBlueprintUpload struct {
+ cmdBlueprint
+ blueprintPath string
+}
+
+func buildCmdBlueprintUpload() *cli.Command {
+ cmd := &cmdBlueprintUpload{}
+ return &cli.Command{
+ Name: "upload",
+ Usage: "Upload the blueprint to the Platform",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.upload(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdBlueprintUpload) Flags() []cli.Flag {
+ flags := c.cmdBlueprint.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "path",
+ Aliases: []string{"p"},
+ Usage: "blueprint path (zip file or directory)",
+ Destination: &c.blueprintPath,
+ Required: true,
+ })
+}
+
+func (c *cmdBlueprintUpload) upload(ctx context.Context) error {
+ return uploadBlueprint(ctx, c.blueprintPath, c.doHTTPRequest)
+}
+
+func uploadBlueprintAndReturnBlueprintID(ctx context.Context, blueprintPath string,
+ doHTTPRequest func(context.Context, doHTTPRequestParams) error,
+) (string, error) {
+ var blueprintID string
+ err := uploadBlueprint(ctx, blueprintPath, func(ctx context.Context, reqParams doHTTPRequestParams) error {
+ reqParams.RespProcessor = func(resp *http.Response) error {
+ if resp.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(resp), 1)
+ }
+
+ var respBlueprint struct {
+ Blueprint struct {
+ ID string `json:"id"`
+ } `json:"blueprint"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&respBlueprint); err != nil {
+ return fmt.Errorf("decode blueprint response: %w", err)
+ }
+ blueprintID = respBlueprint.Blueprint.ID
+ return nil
+ }
+ return doHTTPRequest(ctx, reqParams)
+ })
+ return blueprintID, err
+}
+
+func uploadBlueprint(
+ ctx context.Context, blueprintPath string,
+ doHTTPRequest func(context.Context, doHTTPRequestParams) error,
+) error {
+ fi, err := os.Stat(blueprintPath)
+ if err != nil {
+ return fmt.Errorf("check blueprint path: %w", err)
+ }
+
+ var data []byte
+ if fi.IsDir() {
+ data, err = zipDir(blueprintPath)
+ if err != nil {
+ return fmt.Errorf("zip blueprint directory: %w", err)
+ }
+ } else {
+ data, err = os.ReadFile(blueprintPath)
+ if err != nil {
+ return fmt.Errorf("read blueprint zip file: %w", err)
+ }
+ }
+
+ return doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/blueprints/upload",
+ Body: bytes.NewReader(data),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_connection.go b/internal/app/enaptercli/cmd_connection.go
new file mode 100644
index 0000000..e8d91d8
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection.go
@@ -0,0 +1,18 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v2"
+)
+
+func buildCmdConnection() *cli.Command {
+ return &cli.Command{
+ Name: "connection",
+ Usage: "Manage connections to Enapter Cloud and Gateways",
+ Subcommands: []*cli.Command{
+ buildCmdConnectionAdd(),
+ buildCmdConnectionRemove(),
+ buildCmdConnectionList(),
+ buildCmdConnectionSetDefault(),
+ },
+ }
+}
diff --git a/internal/app/enaptercli/cmd_connection_add.go b/internal/app/enaptercli/cmd_connection_add.go
new file mode 100644
index 0000000..f962682
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_add.go
@@ -0,0 +1,150 @@
+package enaptercli
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/configfile"
+)
+
+type cmdConnectionAdd struct {
+ name string
+ url string
+ token string
+ siteID string
+ gateway bool
+ allowInsecure bool
+}
+
+func buildCmdConnectionAdd() *cli.Command {
+ cmd := &cmdConnectionAdd{}
+ return &cli.Command{
+ Name: "add",
+ Usage: "Add a new connection",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "connection name",
+ Destination: &cmd.name,
+ Required: true,
+ },
+ &cli.BoolFlag{
+ Name: "gateway",
+ Usage: "indicates that the connection is to a Gateway",
+ Destination: &cmd.gateway,
+ },
+ &cli.StringFlag{
+ Name: "url",
+ Usage: "Enapter API base URL",
+ Destination: &cmd.url,
+ Value: defaultURL,
+ },
+ &cli.StringFlag{
+ Name: "token",
+ Usage: "Enapter API access token",
+ Destination: &cmd.token,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "site-id",
+ Usage: "if specified, the connection will be limited to this site " +
+ "(available only for Cloud connections)",
+ Destination: &cmd.siteID,
+ },
+ &cli.BoolFlag{
+ Name: "allow-insecure",
+ Usage: "allow insecure connections to the Enapter API",
+ Destination: &cmd.allowInsecure,
+ },
+ },
+ Action: cmd.do,
+ }
+}
+
+func (c *cmdConnectionAdd) do(cliCtx *cli.Context) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ if _, exists := config.Connections[c.name]; exists {
+ return cli.Exit("Connection with the given name already exists.", 1)
+ }
+
+ if u, err := url.Parse(c.url); err != nil {
+ return cli.Exit("Invalid URL format: "+err.Error()+".", 1)
+ } else if u.Scheme != "https" && u.Scheme != "http" {
+ return cli.Exit("URL scheme must be http or https.", 1)
+ }
+
+ if c.gateway {
+ if c.url == defaultURL {
+ return cli.Exit("Gateway connections require a custom URL.", 1)
+ }
+ if c.siteID != "" {
+ return cli.Exit("The site-id option cannot be used with gateway connections.", 1)
+ }
+ siteID, err := c.resolveGatewaySiteID(cliCtx)
+ if err != nil {
+ return err
+ }
+ c.siteID = siteID
+ }
+
+ if config.Connections == nil {
+ config.Connections = make(map[string]configfile.Connection)
+ }
+ config.Connections[c.name] = configfile.Connection{
+ Gateway: c.gateway,
+ URL: c.url,
+ SiteID: c.siteID,
+ Token: configfile.Token{Value: c.token},
+ AllowInsecure: c.allowInsecure,
+ }
+
+ return configfile.Save(config)
+}
+
+func (c *cmdConnectionAdd) resolveGatewaySiteID(cliCtx *cli.Context) (string, error) {
+ client := &http.Client{
+ Transport: &http.Transport{
+ //nolint:gosec // This is needed to allow self-signed certificates on Gateway.
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: c.allowInsecure},
+ },
+ }
+
+ req, err := http.NewRequestWithContext(
+ cliCtx.Context, http.MethodGet, c.url+"/v3/site", nil)
+ if err != nil {
+ return "", fmt.Errorf("new http request: %w", err)
+ }
+
+ req.Header.Set("X-Enapter-Auth-Token", c.token)
+ req.Header.Set("User-Agent", "enapter-cli/"+cliCtx.App.Version)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("send http request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", cli.Exit("Unexpected response from Gateway: "+resp.Status+". ", 1)
+ }
+
+ var siteResp struct {
+ Site struct {
+ ID string `json:"id"`
+ } `json:"site"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&siteResp); err != nil {
+ return "", fmt.Errorf("decode http response: %w", err)
+ }
+
+ return siteResp.Site.ID, nil
+}
diff --git a/internal/app/enaptercli/cmd_connection_list.go b/internal/app/enaptercli/cmd_connection_list.go
new file mode 100644
index 0000000..90a0cd7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_list.go
@@ -0,0 +1,60 @@
+package enaptercli
+
+import (
+ "fmt"
+ "maps"
+ "slices"
+ "text/tabwriter"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/configfile"
+)
+
+type cmdConnectionList struct{}
+
+func buildCmdConnectionList() *cli.Command {
+ cmd := &cmdConnectionList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List all connections",
+ Action: cmd.do,
+ }
+}
+
+func (c *cmdConnectionList) do(cliCtx *cli.Context) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ const padding = 3
+ w := tabwriter.NewWriter(cliCtx.App.Writer, 0, 0, padding, ' ', 0)
+
+ fmt.Fprintln(w, "NAME\tTYPE\tURL\tALLOW INSECURE\tSITE ID")
+
+ names := slices.Sorted(maps.Keys(config.Connections))
+ for _, name := range names {
+ conn := config.Connections[name]
+
+ displayName := name
+ if name == config.DefaultConn {
+ displayName += " *"
+ }
+
+ typ := "cloud"
+ if conn.Gateway {
+ typ = "gateway"
+ }
+
+ allowInsecure := "no"
+ if conn.AllowInsecure {
+ allowInsecure = "yes"
+ }
+
+ fmt.Fprintf(w, "%s\t%s\t%v\t%v\t%v\n",
+ displayName, typ, conn.URL, allowInsecure, conn.SiteID)
+ }
+
+ return w.Flush()
+}
diff --git a/internal/app/enaptercli/cmd_connection_remove.go b/internal/app/enaptercli/cmd_connection_remove.go
new file mode 100644
index 0000000..55bd849
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_remove.go
@@ -0,0 +1,50 @@
+package enaptercli
+
+import (
+ "fmt"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/configfile"
+)
+
+type cmdConnectionRemove struct {
+ name string
+}
+
+func buildCmdConnectionRemove() *cli.Command {
+ cmd := &cmdConnectionRemove{}
+ return &cli.Command{
+ Name: "remove",
+ Usage: "Remove a connection",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "connection name",
+ Destination: &cmd.name,
+ Required: true,
+ },
+ },
+ Action: cmd.do,
+ }
+}
+
+func (c *cmdConnectionRemove) do(cliCtx *cli.Context) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ if _, ok := config.Connections[c.name]; !ok {
+ fmt.Fprintln(cliCtx.App.ErrWriter, "WARNING: unknown connection.")
+ return nil
+ }
+
+ delete(config.Connections, c.name)
+ if config.DefaultConn == c.name {
+ fmt.Fprintln(cliCtx.App.ErrWriter, "WARNING: removed connection was set as default.")
+ config.DefaultConn = ""
+ }
+
+ return configfile.Save(config)
+}
diff --git a/internal/app/enaptercli/cmd_connection_set_default.go b/internal/app/enaptercli/cmd_connection_set_default.go
new file mode 100644
index 0000000..403ed4f
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_set_default.go
@@ -0,0 +1,42 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/configfile"
+)
+
+type cmdConnectionSetDefault struct {
+ name string
+}
+
+func buildCmdConnectionSetDefault() *cli.Command {
+ cmd := &cmdConnectionSetDefault{}
+ return &cli.Command{
+ Name: "set-default",
+ Usage: "Set default connection",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "connection name",
+ Destination: &cmd.name,
+ Required: true,
+ },
+ },
+ Action: cmd.do,
+ }
+}
+
+func (c *cmdConnectionSetDefault) do(*cli.Context) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ if _, ok := config.Connections[c.name]; !ok {
+ return cli.Exit("Unknown connection.", 1)
+ }
+
+ config.DefaultConn = c.name
+ return configfile.Save(config)
+}
diff --git a/internal/app/enaptercli/cmd_device.go b/internal/app/enaptercli/cmd_device.go
new file mode 100644
index 0000000..85a4ae2
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device.go
@@ -0,0 +1,94 @@
+package enaptercli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDevice struct {
+ cmdBase
+ siteID string
+}
+
+func buildCmdDevice() *cli.Command {
+ cmd := &cmdDevice{}
+ return &cli.Command{
+ Name: "device",
+ Usage: "Manage devices",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdDeviceCreate(),
+ buildCmdDeviceList(),
+ buildCmdDeviceGet(),
+ buildCmdDeviceChangeBlueprint(),
+ buildCmdDeviceLogs(),
+ buildCmdDeviceUpdate(),
+ buildCmdDeviceDelete(),
+ buildCmdDeviceCommand(),
+ buildCmdDeviceTelemetry(),
+ buildCmdDeviceCommunicationConfig(),
+ buildCmdDeviceRunTerminal(),
+ },
+ }
+}
+
+func (c *cmdDevice) Flags() []cli.Flag {
+ flags := c.cmdBase.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "site-id",
+ Usage: "site ID",
+ Destination: &c.siteID,
+ })
+}
+
+func (c *cmdDevice) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ path, err := c.buildPath(p.Path)
+ if err != nil {
+ return err
+ }
+ p.Path = path
+ return c.cmdBase.doHTTPRequest(ctx, p)
+}
+
+func (c *cmdDevice) runWebSocket(ctx context.Context, p runWebSocketParams) error {
+ path, err := c.buildPath(p.Path)
+ if err != nil {
+ return err
+ }
+ p.Path = path
+ return c.cmdBase.runWebSocket(ctx, p)
+}
+
+func (c *cmdDevice) validateExpandFlag(cliCtx *cli.Context) error {
+ return validateExpandFlag(cliCtx, c.supportedExpandFields())
+}
+
+func (c *cmdDevice) supportedExpandFields() []string {
+ return []string{"connectivity", "manifest", "properties", "communication", "site"}
+}
+
+func (c *cmdDevice) buildPath(p string) (string, error) {
+ path, err := url.JoinPath("/devices", p)
+ if err != nil {
+ return "", fmt.Errorf("join path: %w", err)
+ }
+
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ if errors.Is(err, errSiteIDMissing) {
+ return path, nil
+ }
+ return "", err
+ }
+
+ path, err = url.JoinPath("/sites", siteID, path)
+ if err != nil {
+ return "", fmt.Errorf("join path: %w", err)
+ }
+
+ return path, nil
+}
diff --git a/internal/app/enaptercli/cmd_device_change_blueprint.go b/internal/app/enaptercli/cmd_device_change_blueprint.go
new file mode 100644
index 0000000..9ad3271
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_change_blueprint.go
@@ -0,0 +1,88 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceChangeBlueprint struct {
+ cmdDevice
+ deviceID string
+ blueprintID string
+ blueprintPath string
+}
+
+func buildCmdDeviceChangeBlueprint() *cli.Command {
+ cmd := &cmdDeviceChangeBlueprint{}
+ return &cli.Command{
+ Name: "change-blueprint",
+ Usage: "Change device blueprint",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceChangeBlueprint) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ }, &cli.StringFlag{
+ Name: "blueprint-id",
+ Aliases: []string{"b"},
+ Usage: "blueprint ID to use as new device blueprint",
+ Destination: &c.blueprintID,
+ }, &cli.StringFlag{
+ Name: "blueprint-path",
+ Usage: "blueprint path (zip file or directory) to use as new device blueprint",
+ Destination: &c.blueprintPath,
+ })
+}
+
+func (c *cmdDeviceChangeBlueprint) Before(cliCtx *cli.Context) error {
+ if err := c.cmdDevice.Before(cliCtx); err != nil {
+ return err
+ }
+ if c.blueprintID != "" && c.blueprintPath != "" {
+ return errOnlyOneBlueprinFlag
+ }
+ if c.blueprintID == "" && c.blueprintPath == "" {
+ return errMissedBlueprintFlag
+ }
+ return c.validateExpandFlag(cliCtx)
+}
+
+func (c *cmdDeviceChangeBlueprint) do(ctx context.Context) error {
+ if c.blueprintPath != "" {
+ blueprintID, err := uploadBlueprintAndReturnBlueprintID(ctx, c.blueprintPath, c.cmdBase.doHTTPRequest)
+ if err != nil {
+ return fmt.Errorf("upload blueprint: %w", err)
+ }
+ c.blueprintID = blueprintID
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "blueprint_id": c.blueprintID,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/" + c.deviceID + "/assign_blueprint",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_command.go b/internal/app/enaptercli/cmd_device_command.go
new file mode 100644
index 0000000..db90388
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_command.go
@@ -0,0 +1,50 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommand struct {
+ cmdDevice
+ deviceID string
+}
+
+func buildCmdDeviceCommand() *cli.Command {
+ cmd := &cmdDeviceCommand{}
+ return &cli.Command{
+ Name: "command",
+ Usage: "Manage device commands",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdDeviceCommandExecute(),
+ buildCmdDeviceCommandList(),
+ buildCmdDeviceCommandGet(),
+ },
+ }
+}
+
+func (c *cmdDeviceCommand) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ },
+ )
+}
+
+func (c *cmdDeviceCommand) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ path, err := url.JoinPath(c.deviceID, "command_executions", p.Path)
+ if err != nil {
+ return fmt.Errorf("join path: %w", err)
+ }
+ p.Path = path
+ return c.cmdDevice.doHTTPRequest(ctx, p)
+}
diff --git a/internal/app/enaptercli/cmd_device_command_execute.go b/internal/app/enaptercli/cmd_device_command_execute.go
new file mode 100644
index 0000000..a04600a
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_command_execute.go
@@ -0,0 +1,85 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommandExecute struct {
+ cmdDevice
+ deviceID string
+ cmdName string
+ cmdArgs string
+ ephemeral bool
+}
+
+func buildCmdDeviceCommandExecute() *cli.Command {
+ cmd := &cmdDeviceCommandExecute{}
+ return &cli.Command{
+ Name: "execute",
+ Usage: "Execute a device command",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCommandExecute) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "command name",
+ Destination: &c.cmdName,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "arguments",
+ Usage: "command arguments (should be a JSON string)",
+ Destination: &c.cmdArgs,
+ },
+ &cli.BoolFlag{
+ Name: "ephemeral",
+ Usage: "run command in ephemeral mode",
+ Destination: &c.ephemeral,
+ Hidden: true,
+ },
+ )
+}
+
+func (c *cmdDeviceCommandExecute) do(ctx context.Context) error {
+ reqBody := struct {
+ Name string `json:"name"`
+ Args json.RawMessage `json:"arguments,omitempty"`
+ Ephemeral bool `json:"ephemeral,omitempty"`
+ }{
+ Name: c.cmdName,
+ Args: json.RawMessage(c.cmdArgs),
+ Ephemeral: c.ephemeral,
+ }
+ data, err := json.Marshal(reqBody)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/" + c.deviceID + "/execute_command",
+ Body: bytes.NewReader(data),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_command_get.go b/internal/app/enaptercli/cmd_device_command_get.go
new file mode 100644
index 0000000..3ddfb25
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_command_get.go
@@ -0,0 +1,67 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommandGet struct {
+ cmdDeviceCommand
+ executionID string
+ expand []string
+}
+
+func buildCmdDeviceCommandGet() *cli.Command {
+ cmd := &cmdDeviceCommandGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Retrieve a device command execution",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCommandGet) Flags() []cli.Flag {
+ flags := c.cmdDeviceCommand.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "execution-id",
+ Usage: "execution ID",
+ Destination: &c.executionID,
+ Required: true,
+ }, &cli.MultiStringFlag{
+ Target: &cli.StringSliceFlag{
+ Name: "expand",
+ Usage: "coma-separated list of expanded options (supported values: log)",
+ },
+ Destination: &c.expand,
+ },
+ )
+}
+
+func (c *cmdDeviceCommandGet) Before(cliCtx *cli.Context) error {
+ if err := c.cmdDevice.Before(cliCtx); err != nil {
+ return err
+ }
+ return validateExpandFlag(cliCtx, []string{"log"})
+}
+
+func (c *cmdDeviceCommandGet) do(ctx context.Context) error {
+ query := url.Values{}
+ if len(c.expand) != 0 {
+ query.Set("expand", strings.Join(c.expand, ","))
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.executionID,
+ Query: query,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_command_list.go b/internal/app/enaptercli/cmd_device_command_list.go
new file mode 100644
index 0000000..3cc6ee4
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_command_list.go
@@ -0,0 +1,32 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommandList struct {
+ cmdDeviceCommand
+}
+
+func buildCmdDeviceCommandList() *cli.Command {
+ cmd := &cmdDeviceCommandList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List device command executions",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCommandList) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_communication_config.go b/internal/app/enaptercli/cmd_device_communication_config.go
new file mode 100644
index 0000000..a060118
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_communication_config.go
@@ -0,0 +1,48 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommunicationConfig struct {
+ cmdDevice
+ deviceID string
+}
+
+func buildCmdDeviceCommunicationConfig() *cli.Command {
+ cmd := &cmdDeviceCommunicationConfig{}
+ return &cli.Command{
+ Name: "communication-config",
+ Usage: "Manage device communication config",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdDeviceCommunicationConfigGenerate(),
+ },
+ }
+}
+
+func (c *cmdDeviceCommunicationConfig) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ },
+ )
+}
+
+func (c *cmdDeviceCommunicationConfig) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ path, err := url.JoinPath(c.deviceID, p.Path)
+ if err != nil {
+ return fmt.Errorf("join path: %w", err)
+ }
+ p.Path = path
+ return c.cmdDevice.doHTTPRequest(ctx, p)
+}
diff --git a/internal/app/enaptercli/cmd_device_communication_config_generate.go b/internal/app/enaptercli/cmd_device_communication_config_generate.go
new file mode 100644
index 0000000..8853668
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_communication_config_generate.go
@@ -0,0 +1,60 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCommunicationConfigGenerate struct {
+ cmdDeviceCommunicationConfig
+ protocol string
+}
+
+func buildCmdDeviceCommunicationConfigGenerate() *cli.Command {
+ cmd := &cmdDeviceCommunicationConfigGenerate{}
+ return &cli.Command{
+ Name: "generate",
+ Usage: "Generate a new communication config for device",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCommunicationConfigGenerate) Flags() []cli.Flag {
+ flags := c.cmdDeviceCommunicationConfig.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "protocol",
+ Usage: "connection protocol (supported values: MQTT, MQTTS)",
+ Destination: &c.protocol,
+ Required: true,
+ },
+ )
+}
+
+func (c *cmdDeviceCommunicationConfigGenerate) do(ctx context.Context) error {
+ reqBody := struct {
+ Protocol string `json:"protocol"`
+ }{
+ Protocol: c.protocol,
+ }
+ data, err := json.Marshal(reqBody)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/generate_config",
+ Body: bytes.NewReader(data),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_create.go b/internal/app/enaptercli/cmd_device_create.go
new file mode 100644
index 0000000..1154c1a
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create.go
@@ -0,0 +1,22 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCreate struct {
+ cmdBase
+}
+
+func buildCmdDeviceCreate() *cli.Command {
+ cmd := &cmdDeviceCreate{}
+ return &cli.Command{
+ Name: "create",
+ Usage: "Create devices of different types",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdDeviceCreateStandalone(),
+ buildCmdDeviceCreateLua(),
+ },
+ }
+}
diff --git a/internal/app/enaptercli/cmd_device_create_lua_device.go b/internal/app/enaptercli/cmd_device_create_lua_device.go
new file mode 100644
index 0000000..8edbf66
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_lua_device.go
@@ -0,0 +1,146 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCreateLua struct {
+ cmdDeviceCreate
+ siteID string
+ deviceName string
+ deviceSlug string
+ runtimeID string
+ blueprintID string
+ blueprintPath string
+}
+
+func buildCmdDeviceCreateLua() *cli.Command {
+ cmd := &cmdDeviceCreateLua{}
+ return &cli.Command{
+ Name: "lua-device",
+ Usage: "Create a new Lua device",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCreateLua) Flags() []cli.Flag {
+ flags := c.cmdDeviceCreate.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "site-id",
+ Usage: "site ID",
+ Destination: &c.siteID,
+ }, &cli.StringFlag{
+ Name: "runtime-id",
+ Aliases: []string{"r"},
+ Usage: "UCM device ID where the new Lua device will run",
+ Destination: &c.runtimeID,
+ Required: true,
+ }, &cli.StringFlag{
+ Name: "device-name",
+ Aliases: []string{"n"},
+ Usage: "name for the new Lua device",
+ Destination: &c.deviceName,
+ Required: true,
+ }, &cli.StringFlag{
+ Name: "device-slug",
+ Usage: "slug for the new Lua device",
+ Destination: &c.deviceSlug,
+ }, &cli.StringFlag{
+ Name: "blueprint-id",
+ Aliases: []string{"b"},
+ Usage: "blueprint ID to use for the new Lua device",
+ Destination: &c.blueprintID,
+ }, &cli.StringFlag{
+ Name: "blueprint-path",
+ Usage: "blueprint path (zip file or directory) to use for the new Lua device",
+ Destination: &c.blueprintPath,
+ })
+}
+
+func (c *cmdDeviceCreateLua) Before(cliCtx *cli.Context) error {
+ if err := c.cmdDeviceCreate.Before(cliCtx); err != nil {
+ return err
+ }
+ if c.blueprintID != "" && c.blueprintPath != "" {
+ return errOnlyOneBlueprinFlag
+ }
+ if c.blueprintID == "" && c.blueprintPath == "" {
+ return errMissedBlueprintFlag
+ }
+ return nil
+}
+
+func (c *cmdDeviceCreateLua) do(ctx context.Context) error {
+ if c.blueprintPath != "" {
+ blueprintID, err := uploadBlueprintAndReturnBlueprintID(ctx, c.blueprintPath, c.doHTTPRequest)
+ if err != nil {
+ return fmt.Errorf("upload blueprint: %w", err)
+ }
+ c.blueprintID = blueprintID
+ }
+
+ // Cloud API does not allow slugs as runtime ID for now
+ runtimeID, err := c.resolveRuntimeID(ctx)
+ if err != nil {
+ return fmt.Errorf("resolve runtime ID: %w", err)
+ }
+
+ body, err := json.Marshal(map[string]interface{}{
+ "runtime_id": runtimeID,
+ "name": c.deviceName,
+ "slug": c.deviceSlug,
+ "blueprint_id": c.blueprintID,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/provisioning/lua_device",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
+
+func (c *cmdDeviceCreateLua) resolveRuntimeID(ctx context.Context) (string, error) {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ if errors.Is(err, errSiteIDMissing) {
+ return c.runtimeID, nil
+ }
+ return "", err
+ }
+
+ var resp struct {
+ Device struct {
+ ID string `json:"id"`
+ } `json:"device"`
+ }
+
+ if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/sites/" + siteID + "/devices/" + c.runtimeID,
+ RespProcessor: func(r *http.Response) error {
+ if r.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(r), 1)
+ }
+ return json.NewDecoder(r.Body).Decode(&resp)
+ },
+ }); err != nil {
+ return "", err
+ }
+
+ return resp.Device.ID, nil
+}
diff --git a/internal/app/enaptercli/cmd_device_create_standalone.go b/internal/app/enaptercli/cmd_device_create_standalone.go
new file mode 100644
index 0000000..39d7090
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_standalone.go
@@ -0,0 +1,74 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceCreateStandalone struct {
+ cmdDeviceCreate
+ siteID string
+ deviceName string
+ deviceSlug string
+}
+
+func buildCmdDeviceCreateStandalone() *cli.Command {
+ cmd := &cmdDeviceCreateStandalone{}
+ return &cli.Command{
+ Name: "standalone",
+ Usage: "Create a new standalone device",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceCreateStandalone) Flags() []cli.Flag {
+ flags := c.cmdDeviceCreate.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "site-id",
+ Aliases: []string{"s"},
+ Usage: "site ID where the device will be created",
+ Destination: &c.siteID,
+ }, &cli.StringFlag{
+ Name: "device-name",
+ Aliases: []string{"n"},
+ Usage: "name for the new device",
+ Destination: &c.deviceName,
+ Required: true,
+ }, &cli.StringFlag{
+ Name: "device-slug",
+ Usage: "slug for the new standalone device",
+ Destination: &c.deviceSlug,
+ })
+}
+
+func (c *cmdDeviceCreateStandalone) do(ctx context.Context) error {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ return err
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "site_id": siteID,
+ "name": c.deviceName,
+ "slug": c.deviceSlug,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/provisioning/standalone",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_delete.go b/internal/app/enaptercli/cmd_device_delete.go
new file mode 100644
index 0000000..67d5ebf
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_delete.go
@@ -0,0 +1,47 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceDelete struct {
+ cmdDevice
+ deviceID string
+}
+
+func buildCmdDeviceDelete() *cli.Command {
+ cmd := &cmdDeviceDelete{}
+ return &cli.Command{
+ Name: "delete",
+ Usage: "Delete a device",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceDelete) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ },
+ )
+}
+
+func (c *cmdDeviceDelete) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodDelete,
+ Path: "/" + c.deviceID,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_get.go b/internal/app/enaptercli/cmd_device_get.go
new file mode 100644
index 0000000..86a06d4
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_get.go
@@ -0,0 +1,67 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceGet struct {
+ cmdDevice
+ deviceID string
+ expand []string
+}
+
+func buildCmdDeviceGet() *cli.Command {
+ cmd := &cmdDeviceGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Retrieve device information",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceGet) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ }, &cli.MultiStringFlag{
+ Target: &cli.StringSliceFlag{
+ Name: "expand",
+ Usage: "coma-separated list of expanded device information (supported values: " +
+ strings.Join(c.supportedExpandFields(), ", ") + ")",
+ },
+ Destination: &c.expand,
+ })
+}
+
+func (c *cmdDeviceGet) Before(cliCtx *cli.Context) error {
+ if err := c.cmdDevice.Before(cliCtx); err != nil {
+ return err
+ }
+ return c.validateExpandFlag(cliCtx)
+}
+
+func (c *cmdDeviceGet) do(ctx context.Context) error {
+ query := url.Values{}
+ if len(c.expand) != 0 {
+ query.Set("expand", strings.Join(c.expand, ","))
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.deviceID,
+ Query: query,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_list.go b/internal/app/enaptercli/cmd_device_list.go
new file mode 100644
index 0000000..808eb48
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_list.go
@@ -0,0 +1,74 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceList struct {
+ cmdDevice
+ expand []string
+ limit int
+}
+
+func buildCmdDeviceList() *cli.Command {
+ cmd := &cmdDeviceList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List user devices ordered by device ID",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceList) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.MultiStringFlag{
+ Target: &cli.StringSliceFlag{
+ Name: "expand",
+ Usage: "coma-separated list of expanded device information (supported values: " +
+ strings.Join(c.supportedExpandFields(), ", ") + ")",
+ },
+ Destination: &c.expand,
+ }, &cli.IntFlag{
+ Name: "limit",
+ Usage: "maximum number of devices to retrieve",
+ Destination: &c.limit,
+ DefaultText: "retrieves all",
+ })
+}
+
+func (c *cmdDeviceList) Before(cliCtx *cli.Context) error {
+ if err := c.cmdDevice.Before(cliCtx); err != nil {
+ return err
+ }
+ return c.validateExpandFlag(cliCtx)
+}
+
+func (c *cmdDeviceList) do(ctx context.Context) error {
+ query := url.Values{}
+ if len(c.expand) != 0 {
+ query.Set("expand", strings.Join(c.expand, ","))
+ }
+
+ doPaginateRequestParams := paginateHTTPRequestParams{
+ ObjectName: "devices",
+ Limit: c.limit,
+ DoFn: c.doHTTPRequest,
+ BaseParams: doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "",
+ Query: query,
+ },
+ }
+
+ return c.doPaginateRequest(ctx, doPaginateRequestParams)
+}
diff --git a/internal/app/enaptercli/cmd_device_logs.go b/internal/app/enaptercli/cmd_device_logs.go
new file mode 100644
index 0000000..b1307a9
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_logs.go
@@ -0,0 +1,202 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceLogs struct {
+ cmdDevice
+ deviceID string
+ follow bool
+ from cli.Timestamp
+ to cli.Timestamp
+ offset int
+ limit int
+ severity string
+ order string
+ showFilter string
+}
+
+func buildCmdDeviceLogs() *cli.Command {
+ cmd := &cmdDeviceLogs{}
+ return &cli.Command{
+ Name: "logs",
+ Usage: "Show device logs",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceLogs) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ }, &cli.BoolFlag{
+ Name: "follow",
+ Aliases: []string{"f"},
+ Usage: "follow the log output",
+ Destination: &c.follow,
+ }, &cli.TimestampFlag{
+ Name: "from",
+ Usage: "from timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)",
+ Destination: &c.from,
+ Layout: time.RFC3339,
+ }, &cli.TimestampFlag{
+ Name: "to",
+ Usage: "to timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)",
+ Destination: &c.to,
+ Layout: time.RFC3339,
+ }, &cli.IntFlag{
+ Name: "limit",
+ Aliases: []string{"l"},
+ Usage: "maximum number of logs to retrieve",
+ Destination: &c.limit,
+ }, &cli.IntFlag{
+ Name: "offset",
+ Aliases: []string{"o"},
+ Usage: "number of logs to skip when retrieving",
+ Destination: &c.offset,
+ }, &cli.StringFlag{
+ Name: "severity",
+ Aliases: []string{"s"},
+ Usage: "filter logs by severity",
+ Destination: &c.severity,
+ }, &cli.StringFlag{
+ Name: "order",
+ Usage: "order logs by criteria (RECEIVED_AT_ASC[default], RECEIVED_AT_DESC)",
+ Destination: &c.order,
+ Action: func(_ *cli.Context, v string) error {
+ if v != "RECEIVED_AT_ASC" && v != "RECEIVED_AT_DESC" {
+ return fmt.Errorf("%w: should be one of [RECEIVED_AT_ASC, RECEIVED_AT_DESC]", errUnsupportedFlagValue)
+ }
+ return nil
+ },
+ }, &cli.StringFlag{
+ Name: "show",
+ Usage: "filter logs by criteria (ALL[default], PERSISTED_ONLY, TEMPORARY_ONLY)",
+ Destination: &c.showFilter,
+ Action: func(_ *cli.Context, v string) error {
+ if v != "ALL" && v != "PERSISTED_ONLY" && v != "TEMPORARY_ONLY" {
+ return fmt.Errorf("%w: should be one of [ALL, PERSISTED_ONLY, TEMPORARY_ONLY]", errUnsupportedFlagValue)
+ }
+ return nil
+ },
+ })
+}
+
+func (c *cmdDeviceLogs) do(ctx context.Context) error {
+ if c.follow {
+ return c.doFollow(ctx)
+ }
+ return c.doList(ctx)
+}
+
+func (c *cmdDeviceLogs) doFollow(ctx context.Context) error {
+ if c.from.Value() != nil {
+ return cli.Exit("Option received_at_from is unsupported in follow mode.", 1)
+ }
+ if c.to.Value() != nil {
+ return cli.Exit("Option received_at_to is unsupported in follow mode.", 1)
+ }
+ if c.offset > 0 {
+ return cli.Exit("Option offset is unsupported in follow mode.", 1)
+ }
+ if c.limit > 0 {
+ return cli.Exit("Option limit is unsupported in follow mode.", 1)
+ }
+ if c.order != "" {
+ return cli.Exit("Option order is unsupported in follow mode.", 1)
+ }
+
+ query := url.Values{}
+ if c.severity != "" {
+ query.Add("severity", c.severity)
+ }
+ if c.showFilter != "" {
+ query.Add("show", c.showFilter)
+ }
+
+ return c.runWebSocket(ctx, runWebSocketParams{
+ Path: "/" + c.deviceID + "/logs",
+ Query: query,
+ RespProcessor: func(r io.Reader) error {
+ var msg struct {
+ Timestamp int64 `json:"timestamp"`
+ ReceivedAt string `json:"received_at"`
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ }
+ if err := json.NewDecoder(r).Decode(&msg); err != nil {
+ return fmt.Errorf("parse payload: %w", err)
+ }
+ fmt.Fprintf(c.writer, "%s [%s] %s\n", msg.ReceivedAt, msg.Severity, msg.Message)
+ return nil
+ },
+ })
+}
+
+func (c *cmdDeviceLogs) doList(ctx context.Context) error {
+ query := url.Values{}
+ if c.from.Value() != nil {
+ query.Add("received_at_from", c.from.Value().Format(time.RFC3339))
+ }
+ if c.to.Value() != nil {
+ query.Add("received_at_to", c.to.Value().Format(time.RFC3339))
+ }
+ if c.offset > 0 {
+ query.Add("offset", strconv.Itoa(c.offset))
+ }
+ if c.limit > 0 {
+ query.Add("limit", strconv.Itoa(c.limit))
+ }
+ if c.severity != "" {
+ query.Add("severity", c.severity)
+ }
+ if c.order != "" {
+ query.Add("order", c.order)
+ }
+ if c.showFilter != "" {
+ query.Add("show", c.showFilter)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.deviceID + "/logs",
+ Query: query,
+ //nolint:bodyclose //body is closed in doHTTPRequest
+ RespProcessor: okRespBodyProcessor(func(body io.Reader) error {
+ var resp struct {
+ Logs []struct {
+ Timestamp int64 `json:"timestamp"`
+ ReceivedAt string `json:"received_at"`
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ } `json:"logs"`
+ }
+ if err := json.NewDecoder(body).Decode(&resp); err != nil {
+ return fmt.Errorf("parse response body: %w", err)
+ }
+ for _, l := range resp.Logs {
+ fmt.Fprintf(c.writer, "%s [%s] %s\n", l.ReceivedAt, l.Severity, l.Message)
+ }
+ return nil
+ }),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_run_terminal.go b/internal/app/enaptercli/cmd_device_run_terminal.go
new file mode 100644
index 0000000..4da2af7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_run_terminal.go
@@ -0,0 +1,259 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/urfave/cli/v2"
+ "golang.org/x/term"
+)
+
+type cmdDeviceRunTerminal struct {
+ cmdDevice
+ deviceID string
+ winWidth int
+ winHeight int
+}
+
+func buildCmdDeviceRunTerminal() *cli.Command {
+ cmd := &cmdDeviceRunTerminal{}
+ return &cli.Command{
+ Name: "run-terminal",
+ Usage: "Run new remote terminal session",
+ Description: "Remote terminal feature should be enabled in gateway settings. " +
+ "Use Ctrl+] sequence to force connection close.",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceRunTerminal) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "gateway device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ })
+}
+
+func (c *cmdDeviceRunTerminal) do(ctx context.Context) error {
+ fin := os.Stdin
+ fd := int(fin.Fd())
+ if !term.IsTerminal(fd) {
+ return cli.Exit("Standard input should be a terminal.", 1)
+ }
+
+ var credentials struct {
+ ChannelID string `json:"channel_id"`
+ Token string `json:"token"`
+ WebSocketURL string `json:"websocket_url"`
+ }
+ if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/" + c.deviceID + "/run_terminal",
+ RespProcessor: func(r *http.Response) error {
+ if r.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(r), 1)
+ }
+ return json.NewDecoder(r.Body).Decode(&credentials)
+ },
+ }); err != nil {
+ return err
+ }
+
+ url, err := url.Parse(credentials.WebSocketURL)
+ if err != nil {
+ return fmt.Errorf("parse url: %w", err)
+ }
+ headers := make(http.Header)
+ headers.Set("Authorization", "Bearer "+credentials.Token)
+
+ conn, err := c.dialWebSocket(ctx, url, headers)
+ if err != nil {
+ return fmt.Errorf("dial websocket: %w", err)
+ }
+ defer conn.Close()
+
+ oldState, err := term.MakeRaw(fd)
+ if err != nil {
+ return fmt.Errorf("make raw terminal: %w", err)
+ }
+ defer func() { _ = term.Restore(fd, oldState) }()
+
+ // TODO: wait for pong?
+ if err := c.writePing(conn, credentials.ChannelID); err != nil {
+ return fmt.Errorf("ping: %w", err)
+ }
+
+ fmt.Fprint(c.writer, "Use Ctrl+] to terminate the session.\r\n\r\n")
+
+ errCh := make(chan error)
+ stdinCh := make(chan byte)
+ go func() { errCh <- c.runFileReader(ctx, fin, stdinCh) }()
+ go func() { errCh <- c.runConnReader(ctx, conn) }()
+
+ return c.run(ctx, conn, credentials.ChannelID, stdinCh, errCh, fd)
+}
+
+func (c *cmdDeviceRunTerminal) runFileReader(
+ ctx context.Context, f *os.File, ch chan<- byte,
+) error {
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ }
+
+ var buf [1]byte
+ n, err := f.Read(buf[:])
+ if err != nil {
+ return err
+ }
+
+ if n > 0 {
+ select {
+ case <-ctx.Done():
+ return nil
+ case ch <- buf[0]:
+ }
+ }
+ }
+}
+
+func (c *cmdDeviceRunTerminal) runConnReader(
+ ctx context.Context, conn *websocket.Conn,
+) error {
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ }
+
+ var msg struct {
+ Data string `json:"data"`
+ }
+ if err := conn.ReadJSON(&msg); err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+
+ var data []string
+ if err := json.Unmarshal([]byte(msg.Data), &data); err != nil {
+ return fmt.Errorf("unmarhal data: %w", err)
+ }
+
+ if len(data) == 0 {
+ return cli.Exit("Unexpected payload from server.", 1)
+ }
+ if data[0] == "exit" {
+ return nil
+ }
+ if data[0] == "stdin" && len(data) > 1 {
+ fmt.Fprint(c.writer, data[1])
+ }
+ }
+}
+
+func (c *cmdDeviceRunTerminal) run(
+ ctx context.Context, conn *websocket.Conn, channelID string,
+ stdinCh <-chan byte, errCh <-chan error, fd int,
+) error {
+ const keepAliveInterval = 30 * time.Second
+ const updateSizeInterval = time.Second
+
+ keepAliveTicker := time.NewTicker(keepAliveInterval)
+ updateSizeTicker := time.NewTicker(updateSizeInterval)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case err := <-errCh:
+ return err
+ default:
+ }
+
+ select {
+ case <-ctx.Done():
+ return nil
+ case err := <-errCh:
+ return err
+ case <-keepAliveTicker.C:
+ if err := c.writeKeepalive(conn, channelID); err != nil {
+ return err
+ }
+ case <-updateSizeTicker.C:
+ if err := c.writeSetSize(conn, channelID, fd); err != nil {
+ return err
+ }
+ case b := <-stdinCh:
+ const GS = 29 // ^]
+ if b == GS {
+ return cli.Exit("Exiting session.", 0)
+ }
+ if err := c.writeStdin(conn, channelID, b); err != nil {
+ return err
+ }
+ }
+ }
+}
+
+func (c *cmdDeviceRunTerminal) writePing(conn *websocket.Conn, channelID string) error {
+ return conn.WriteJSON(map[string]any{
+ "channel": channelID,
+ "data": `["ping"]`,
+ })
+}
+
+func (c *cmdDeviceRunTerminal) writeStdin(conn *websocket.Conn, channelID string, b byte) error {
+ data, err := json.Marshal([]string{"stdin", string(b)})
+ if err != nil {
+ return err
+ }
+ return conn.WriteJSON(map[string]any{
+ "channel": channelID,
+ "data": string(data),
+ })
+}
+
+func (c *cmdDeviceRunTerminal) writeKeepalive(conn *websocket.Conn, channelID string) error {
+ return conn.WriteJSON(map[string]any{
+ "channel": channelID,
+ "data": `["keepalive_ping"]`,
+ })
+}
+
+func (c *cmdDeviceRunTerminal) writeSetSize(conn *websocket.Conn, channelID string, fd int) error {
+ w, h, err := term.GetSize(fd)
+ if err != nil {
+ // FIXME: error on Windows
+ return nil
+ }
+ if c.winWidth == w && c.winHeight == h {
+ return nil
+ }
+
+ if err := conn.WriteJSON(map[string]any{
+ "channel": channelID,
+ "data": fmt.Sprintf("[%q, %d, %d]", "set_size", h, w),
+ }); err != nil {
+ return err
+ }
+
+ c.winWidth = w
+ c.winHeight = h
+ return nil
+}
diff --git a/internal/app/enaptercli/cmd_device_telemetry.go b/internal/app/enaptercli/cmd_device_telemetry.go
new file mode 100644
index 0000000..d86b52a
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_telemetry.go
@@ -0,0 +1,84 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceTelemetry struct {
+ cmdDevice
+ deviceID string
+ follow bool
+}
+
+func buildCmdDeviceTelemetry() *cli.Command {
+ cmd := &cmdDeviceTelemetry{}
+ return &cli.Command{
+ Name: "telemetry",
+ Usage: "Show device telemetry",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceTelemetry) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ }, &cli.BoolFlag{
+ Name: "follow",
+ Aliases: []string{"f"},
+ Usage: "follow the telemetry output",
+ Destination: &c.follow,
+ })
+}
+
+func (c *cmdDeviceTelemetry) do(ctx context.Context) error {
+ if c.follow {
+ return c.doFollow(ctx)
+ }
+ return c.doList(ctx)
+}
+
+func (c *cmdDeviceTelemetry) doFollow(ctx context.Context) error {
+ return c.runWebSocket(ctx, runWebSocketParams{
+ Path: "/" + c.deviceID + "/telemetry",
+ RespProcessor: func(r io.Reader) error {
+ payload, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("read response: %w", err)
+ }
+ fmt.Fprintln(c.writer, strings.TrimSpace(string(payload)))
+ return nil
+ },
+ })
+}
+
+func (c *cmdDeviceTelemetry) doList(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.deviceID + "/telemetry",
+ //nolint:bodyclose //body is closed in doHTTPRequest
+ RespProcessor: okRespBodyProcessor(func(r io.Reader) error {
+ payload, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("read response: %w", err)
+ }
+ fmt.Fprintln(c.writer, strings.TrimSpace(string(payload)))
+ return nil
+ }),
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_update.go b/internal/app/enaptercli/cmd_device_update.go
new file mode 100644
index 0000000..a4ebb31
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_update.go
@@ -0,0 +1,73 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceUpdate struct {
+ cmdDevice
+ deviceID string
+ name string
+ slug string
+}
+
+func buildCmdDeviceUpdate() *cli.Command {
+ cmd := &cmdDeviceUpdate{}
+ return &cli.Command{
+ Name: "update",
+ Usage: "Update a device",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceUpdate) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags,
+ &cli.StringFlag{
+ Name: "device-id",
+ Aliases: []string{"d"},
+ Usage: "device ID",
+ Destination: &c.deviceID,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "device name",
+ Destination: &c.name,
+ },
+ &cli.StringFlag{
+ Name: "slug",
+ Usage: "device slug",
+ Destination: &c.slug,
+ },
+ )
+}
+
+func (c *cmdDeviceUpdate) do(ctx context.Context) error {
+ payload := map[string]string{
+ "name": c.name,
+ "slug": c.slug,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPatch,
+ Path: "/" + c.deviceID,
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_devices.go b/internal/app/enaptercli/cmd_devices.go
deleted file mode 100644
index 7782b4a..0000000
--- a/internal/app/enaptercli/cmd_devices.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package enaptercli
-
-import "github.com/urfave/cli/v2"
-
-type cmdDevices struct {
- cmdBase
- hardwareID string
-}
-
-func buildCmdDevices() *cli.Command {
- return &cli.Command{
- Name: "devices",
- Usage: "Device information and management commands.",
- Subcommands: []*cli.Command{
- buildCmdDevicesUpload(),
- buildCmdDevicesLogs(),
- buildCmdDevicesUploadLogs(),
- buildCmdDevicesExecute(),
- },
- }
-}
-
-func (c *cmdDevices) Flags() []cli.Flag {
- flags := c.cmdBase.Flags()
- flags = append(flags, &cli.StringFlag{
- Name: "hardware-id",
- Usage: "Hardware ID of the device; can be obtained in cloud.enapter.com",
- Required: true,
- Destination: &c.hardwareID,
- })
- return flags
-}
diff --git a/internal/app/enaptercli/cmd_devices_execute.go b/internal/app/enaptercli/cmd_devices_execute.go
deleted file mode 100644
index 6f15714..0000000
--- a/internal/app/enaptercli/cmd_devices_execute.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/publichttp"
-)
-
-type cmdDevicesExecute struct {
- cmdDevices
- commandName string
- arguments string
- showProgress bool
-}
-
-func buildCmdDevicesExecute() *cli.Command {
- cmd := &cmdDevicesExecute{}
-
- return &cli.Command{
- Name: "execute",
- Usage: "Execute command on device",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.execute(cliCtx.Context)
- },
- }
-}
-
-func (c *cmdDevicesExecute) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.StringFlag{
- Name: "command",
- Usage: "Command name",
- Required: true,
- Destination: &c.commandName,
- },
- &cli.StringFlag{
- Name: "arguments",
- Usage: "Command arguments as JSON object",
- Destination: &c.arguments,
- },
- &cli.BoolFlag{
- Name: "show-progress",
- Usage: "Enable in-progress responses streaming",
- Destination: &c.showProgress,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesExecute) execute(ctx context.Context) error {
- transport := publichttp.NewAuthTokenTransport(http.DefaultTransport, c.token)
- client, err := publichttp.NewClientWithURL(&http.Client{Transport: transport}, c.apiHost)
- if err != nil {
- return fmt.Errorf("create http client: %w", err)
- }
-
- var arguments map[string]interface{}
- if c.arguments != "" {
- if err := json.Unmarshal([]byte(c.arguments), &arguments); err != nil {
- return fmt.Errorf("parse arguments: %w", err)
- }
- }
-
- query := publichttp.CommandQuery{
- HardwareID: c.hardwareID,
- CommandName: c.commandName,
- Arguments: arguments,
- }
-
- if c.showProgress {
- return c.executeWithProgress(ctx, client, query)
- }
-
- response, err := client.Commands.Execute(ctx, query)
- if err != nil {
- return err
- }
- return c.print(response)
-}
-
-func (c *cmdDevicesExecute) executeWithProgress(
- ctx context.Context, client *publichttp.Client,
- query publichttp.CommandQuery,
-) error {
- progressCh, err := client.Commands.ExecuteWithProgress(ctx, query)
- if err != nil {
- return err
- }
-
- for progress := range progressCh {
- if progress.Error != nil {
- return progress.Error
- }
- err := c.print(progress.CommandResponse)
- if err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (c *cmdDevicesExecute) print(r publichttp.CommandResponse) error {
- s, err := json.Marshal(r)
- if err != nil {
- return fmt.Errorf("format response: %w", err)
- }
- fmt.Fprintln(c.writer, string(s))
- return nil
-}
diff --git a/internal/app/enaptercli/cmd_devices_execute_test.go b/internal/app/enaptercli/cmd_devices_execute_test.go
deleted file mode 100644
index 3b69ecc..0000000
--- a/internal/app/enaptercli/cmd_devices_execute_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package enaptercli_test
-
-import (
- "bytes"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-func TestDeviceExecute(t *testing.T) {
- t.Run("simple", func(t *testing.T) {
- basePath := "testdata/device_execute/simple"
- showProgress := false
- testDeviceExecute(t, basePath, showProgress, http.StatusOK)
- })
-
- t.Run("progress", func(t *testing.T) {
- basePath := "testdata/device_execute/progress"
- showProgress := true
- testDeviceExecute(t, basePath, showProgress, http.StatusOK)
- })
-
- t.Run("error", func(t *testing.T) {
- basePath := "testdata/device_execute/error"
- showProgress := false
- testDeviceExecute(t, basePath, showProgress, http.StatusForbidden)
- })
-}
-
-func testDeviceExecute(
- t *testing.T, basePath string, showProgress bool, statusCode int,
-) {
- resp := readFileLines(t, filepath.Join(basePath, "responses"))
- server := startExecuteTestServer(showProgress, statusCode, resp)
- defer server.Close()
-
- args := []string{"enapter", "devices", "execute"}
- args = append(args,
- "--token", faker.Word(),
- "--hardware-id", faker.Word(),
- "--command", faker.Word(),
- "--api-host", server.URL)
- if showProgress {
- args = append(args, "--show-progress")
- }
-
- checkExecuteTestAppOutput(t, basePath, args)
-}
-
-func readFileLines(t *testing.T, path string) [][]byte {
- f, err := os.ReadFile(path)
- require.NoError(t, err)
- return bytes.Split(f, []byte{'\n'})
-}
-
-func startExecuteTestServer(
- showProgress bool, statusCode int, responses [][]byte,
-) *httptest.Server {
- handler := func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(statusCode)
-
- for _, r := range responses {
- _, _ = w.Write(append(r, '\n'))
- if showProgress {
- w.(http.Flusher).Flush()
- }
- }
- }
- return httptest.NewServer(http.HandlerFunc(handler))
-}
-
-func checkExecuteTestAppOutput(t *testing.T, basePath string, args []string) {
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
-
- actual, err := io.ReadAll(app.Stdout())
- require.NoError(t, err)
-
- if appErr != nil {
- actual = append(actual, []byte("app exit with error: "+appErr.Error()+"\n")...)
- }
-
- expectedFileName := filepath.Join(basePath, "output")
- if update {
- err := os.WriteFile(expectedFileName, actual, 0o600)
- require.NoError(t, err)
- }
-
- expected, err := os.ReadFile(expectedFileName)
- require.NoError(t, err)
-
- require.Equal(t, string(expected), string(actual))
-}
diff --git a/internal/app/enaptercli/cmd_devices_logs.go b/internal/app/enaptercli/cmd_devices_logs.go
deleted file mode 100644
index 1436c40..0000000
--- a/internal/app/enaptercli/cmd_devices_logs.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-type cmdDevicesLogs struct {
- cmdDevices
-}
-
-func buildCmdDevicesLogs() *cli.Command {
- cmd := &cmdDevicesLogs{}
-
- return &cli.Command{
- Name: "logs",
- Usage: "Stream logs from a device",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesLogs) run(ctx context.Context, version string) error {
- writer, err := cloudapi.NewDeviceLogsWriter(c.websocketsURL, c.token,
- version, c.hardwareID, c.writeLog)
- if err != nil {
- return fmt.Errorf("create writer: %w", err)
- }
- return writer.Run(ctx)
-}
-
-func (c *cmdDevicesLogs) writeLog(topic, msg string) {
- fmt.Fprintf(c.writer, "[%s] %s\n", topic, msg)
-}
diff --git a/internal/app/enaptercli/cmd_devices_logs_test.go b/internal/app/enaptercli/cmd_devices_logs_test.go
deleted file mode 100644
index 250c353..0000000
--- a/internal/app/enaptercli/cmd_devices_logs_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//nolint:dupl // not a duplicate of `rules logs` command tests
-package enaptercli_test
-
-import (
- "strings"
- "testing"
-)
-
-func TestDeviceLogs(t *testing.T) {
- t.Run("simple", func(t *testing.T) {
- inputFileName := "testdata/device_logs/simple/input"
- untilLinePrefix := "[telemetry]"
- expectedFileName := "testdata/device_logs/simple/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("invalid token", func(t *testing.T) {
- inputFileName := "testdata/device_logs/disconnect/invalid_token/input"
- untilLinePrefix := "[connection]"
- expectedFileName := "testdata/device_logs/disconnect/invalid_token/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("device not found", func(t *testing.T) {
- inputFileName := "testdata/device_logs/disconnect/device_not_found/input"
- untilLinePrefix := "[connection] disconnected"
- expectedFileName := "testdata/device_logs/disconnect/device_not_found/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-}
-
-func testDeviceLogs(t *testing.T, inputFileName, untilLinePrefix, expectedFileName string) {
- const hardwareID = "SIM-WTM"
-
- identifier := map[string]string{"channel": "DeviceChannel", "hardware_id": hardwareID}
-
- command := strings.Split("enapter devices logs", " ")
- command = append(command, "--hardware-id", hardwareID)
-
- testLogsCommand(t, inputFileName, untilLinePrefix, expectedFileName, identifier, command)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload.go b/internal/app/enaptercli/cmd_devices_upload.go
deleted file mode 100644
index d8c485e..0000000
--- a/internal/app/enaptercli/cmd_devices_upload.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package enaptercli
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-const deviceUploadDefaultTimeout = 30 * time.Second
-
-type cmdDevicesUpload struct {
- cmdDevices
- blueprintDir string
- timeout time.Duration
-}
-
-func buildCmdDevicesUpload() *cli.Command {
- cmd := &cmdDevicesUpload{}
-
- return &cli.Command{
- Name: "upload",
- Usage: "Upload blueprint to a device",
- Description: "Blueprint combines device capabilities declaration and Lua firmware for Enapter UCM. " +
- "The command updates device blueprint and uploads the firmware to the UCM. Learn more " +
- "about Enapter Blueprints at https://handbook.enapter.com/blueprints.",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.upload(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesUpload) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.DurationFlag{
- Name: "timeout",
- Usage: "Time to wait for blueprint uploading",
- Destination: &c.timeout,
- Value: deviceUploadDefaultTimeout,
- },
- &cli.StringFlag{
- Name: "blueprint-dir",
- Usage: "Directory which contains blueprint file",
- Required: true,
- Destination: &c.blueprintDir,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesUpload) upload(ctx context.Context, version string) error {
- if c.timeout != 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, c.timeout)
- defer cancel()
- }
-
- files, err := c.blueprintFilesList()
- if err != nil {
- return err
- }
-
- fmt.Fprintln(c.writer, "Blueprint files to be uploaded:")
- for _, name := range files {
- fmt.Fprintln(c.writer, "*", name)
- }
-
- zipBytes, err := c.blueprintZip()
- if err != nil {
- return err
- }
-
- onceWriter := &onceWriter{w: c.writer}
- transport := cloudapi.NewCredentialsTransport(http.DefaultTransport, c.token, version)
- transport = cloudapi.NewCLIMessageWriterTransport(transport, onceWriter)
- client := cloudapi.NewClientWithURL(&http.Client{Transport: transport}, c.graphqlURL)
-
- uploadData, uploadErrors, err := client.UploadBlueprint(ctx, c.hardwareID, zipBytes)
- if err != nil {
- return fmt.Errorf("do update: %w", err)
- }
-
- if len(uploadErrors) != 0 {
- for _, e := range uploadErrors {
- fmt.Fprintln(c.writer, "[ERROR]", e.Message)
- }
- return errFinishedWithError
- }
-
- fmt.Fprintln(c.writer, "upload started with operation id", uploadData.OperationID)
-
- err = client.WriteOperationLogs(ctx, c.hardwareID, uploadData.OperationID, c.writeLog)
- if err != nil {
- return fmt.Errorf("receive operation logs: %w", err)
- }
-
- fmt.Fprintln(c.writer, "Done!")
- return nil
-}
-
-func (c *cmdDevicesUpload) blueprintFilesList() ([]string, error) {
- var files []string
-
- err := filepath.Walk(c.blueprintDir,
- func(name string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if !info.IsDir() {
- files = append(files, name)
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
-
- return files, nil
-}
-
-func (c *cmdDevicesUpload) blueprintZip() ([]byte, error) {
- bpBytes, err := zipDir(c.blueprintDir)
- if err != nil {
- return nil, fmt.Errorf("failed to zip blueprint dir %q: %w", c.blueprintDir, err)
- }
-
- zipBuf := &bytes.Buffer{}
- zipBuf.WriteString("data:application/gzip;base64,")
- enc := base64.NewEncoder(base64.StdEncoding, zipBuf)
- _, err = enc.Write(bpBytes)
- if err != nil {
- return nil, fmt.Errorf("failed to encode blueprint as base64: %w", err)
- }
-
- if err := enc.Close(); err != nil {
- return nil, fmt.Errorf("failed to encode blueprint as base64: %w", err)
- }
-
- return zipBuf.Bytes(), nil
-}
-
-func (c *cmdDevicesUpload) writeLog(operationID string, l cloudapi.OperationLog) {
- fmt.Fprintf(c.writer, "[#%s] %s [%s] %s\n", operationID, l.CreatedAt, l.Severity, l.Payload)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_logs.go b/internal/app/enaptercli/cmd_devices_upload_logs.go
deleted file mode 100644
index 7051808..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_logs.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
- "net/http"
- "time"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-type cmdDevicesUploadLogs struct {
- cmdDevices
- operationID string
- timeout time.Duration
-}
-
-func buildCmdDevicesUploadLogs() *cli.Command {
- cmd := &cmdDevicesUploadLogs{}
-
- return &cli.Command{
- Name: "upload-logs",
- Usage: "Show blueprint uploading logs",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesUploadLogs) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.DurationFlag{
- Name: "timeout",
- Usage: "Time to wait for blueprint uploading",
- Destination: &c.timeout,
- Value: deviceUploadDefaultTimeout,
- },
- &cli.StringFlag{
- Name: "operation-id",
- Usage: "Uploading operation ID (optional)",
- Destination: &c.operationID,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesUploadLogs) run(ctx context.Context, version string) error {
- if c.timeout != 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, c.timeout)
- defer cancel()
- }
-
- transport := cloudapi.NewCredentialsTransport(http.DefaultTransport, c.token, version)
- transport = cloudapi.NewCLIMessageWriterTransport(transport, &onceWriter{w: c.writer})
- client := cloudapi.NewClientWithURL(&http.Client{Transport: transport}, c.graphqlURL)
-
- if c.operationID != "" {
- return client.WriteOperationLogs(ctx, c.hardwareID, c.operationID, c.writeLog)
- }
-
- const lastOperationsNumber = 2
- return client.WriteLastOperationsLogs(ctx, c.hardwareID, lastOperationsNumber, c.writeLog)
-}
-
-func (c *cmdDevicesUploadLogs) writeLog(operationID string, l cloudapi.OperationLog) {
- fmt.Fprintf(c.writer, "[#%s] %s [%s] %s\n", operationID, l.CreatedAt, l.Severity, l.Payload)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_logs_test.go b/internal/app/enaptercli/cmd_devices_upload_logs_test.go
deleted file mode 100644
index d574df4..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_logs_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package enaptercli_test
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-func TestDeviceUploadLogs(t *testing.T) {
- errorsDir := "testdata/device_upload_logs"
- dirs, err := os.ReadDir(errorsDir)
- require.NoError(t, err)
-
- for _, dir := range dirs {
- if !dir.IsDir() {
- continue
- }
-
- dir := dir
- t.Run(dir.Name(), func(t *testing.T) {
- testDeviceUploadLogs(t, filepath.Join(errorsDir, dir.Name()))
- })
- }
-}
-
-type devicesUploadLogsTestSettings struct {
- OperationID string `json:"operation_id"`
- HardwareID string `json:"hardware_id"`
- CliMessage string `json:"cli_message"`
- Token string `json:"-"`
-}
-
-func (s *devicesUploadLogsTestSettings) Fill(t *testing.T, dir string) {
- settingsBytes, err := os.ReadFile(filepath.Join(dir, "settings.json"))
- require.NoError(t, err)
- require.NoError(t, json.Unmarshal(settingsBytes, s))
- s.Token = faker.Word()
-}
-
-func testDeviceUploadLogs(t *testing.T, dir string) {
- var settings devicesUploadLogsTestSettings
- settings.Fill(t, dir)
-
- reqs := byteSliceSliceFromFile(t, filepath.Join(dir, "requests"))
- resps := byteSliceSliceFromFile(t, filepath.Join(dir, "responses"))
-
- srv := startTestServer(reqs, resps, settings.CliMessage)
- defer srv.Close()
-
- args := strings.Split("enapter devices upload-logs", " ")
- args = append(args,
- "--token", settings.Token,
- "--hardware-id", settings.HardwareID,
- "--gql-api-url", srv.URL)
- if settings.OperationID != "" {
- args = append(args, "--operation-id", settings.OperationID)
- }
-
- checkTestAppOutput(t, dir, args, reqs)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_test.go b/internal/app/enaptercli/cmd_devices_upload_test.go
deleted file mode 100644
index 4cfe889..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package enaptercli_test
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-const blueprintDir = "testdata/device_upload/simple/blueprint"
-
-func TestDeviceUpload(t *testing.T) {
- testdataDir := "testdata/device_upload"
- dirs, err := os.ReadDir(testdataDir)
- require.NoError(t, err)
-
- for _, dir := range dirs {
- if !dir.IsDir() {
- continue
- }
-
- dir := dir
- t.Run(dir.Name(), func(t *testing.T) {
- testDeviceUpload(t, filepath.Join(testdataDir, dir.Name()), blueprintDir)
- })
- }
-}
-
-func TestDeviceUploadBlueprintDirWithDot(t *testing.T) {
- testDeviceUpload(t, "testdata/device_upload/simple", "./"+blueprintDir)
-}
-
-func TestDeviceUploadWrongBlueprintDir(t *testing.T) {
- args := strings.Split("enapter devices upload --token token --hardware-id hardwareID "+
- "--gql-api-url apiURL --blueprint-dir wrong", " ")
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
- require.EqualError(t, appErr, `lstat wrong: no such file or directory`)
-}
-
-type devicesUploadTestSettings struct {
- HardwareID string `json:"hardware_id"`
- CliMessage string `json:"cli_message"`
- Token string `json:"-"`
-}
-
-func (s *devicesUploadTestSettings) Fill(t *testing.T, dir string) {
- settingsBytes, err := os.ReadFile(filepath.Join(dir, "settings.json"))
- require.NoError(t, err)
- require.NoError(t, json.Unmarshal(settingsBytes, s))
- s.Token = faker.Word()
-}
-
-func testDeviceUpload(t *testing.T, dir, blueprintDir string) {
- var settings devicesUploadTestSettings
- settings.Fill(t, dir)
-
- reqs := byteSliceSliceFromFile(t, filepath.Join(dir, "requests"))
- resps := byteSliceSliceFromFile(t, filepath.Join(dir, "responses"))
-
- srv := startTestServer(reqs, resps, settings.CliMessage)
- defer srv.Close()
-
- args := strings.Split("enapter devices upload", " ")
- args = append(args,
- "--token", settings.Token,
- "--hardware-id", settings.HardwareID,
- "--blueprint-dir", blueprintDir,
- "--gql-api-url", srv.URL)
-
- checkTestAppOutput(t, dir, args, reqs)
-}
diff --git a/internal/app/enaptercli/cmd_logs_test.go b/internal/app/enaptercli/cmd_logs_test.go
deleted file mode 100644
index 0db80b7..0000000
--- a/internal/app/enaptercli/cmd_logs_test.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package enaptercli_test
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "reflect"
- "strings"
- "testing"
- "time"
-
- "github.com/bxcodec/faker/v3"
- "github.com/gorilla/websocket"
- "github.com/stretchr/testify/require"
-)
-
-func testLogsCommand(
- t *testing.T, inputFileName, untilLinePrefix, expectedFileName string,
- identifier map[string]string, args []string,
-) {
- token := faker.Word()
- messagesBytes, err := os.ReadFile(inputFileName)
- require.NoError(t, err)
- messages := bytes.Split(messagesBytes, []byte{'\n'})
-
- handleErrCh := make(chan string)
- wsPath, srv := startTestLogsServer(t, token, identifier, messages, handleErrCh)
- defer srv.Close()
-
- args = append(args, "--token", token, "--ws-api-url", wsPath)
- app := startTestApp(args...)
- defer app.Stop()
-
- actual := readOutputUntilLineOrError(t, app.Stdout(), untilLinePrefix, handleErrCh)
- if update {
- err := os.WriteFile(expectedFileName, []byte(actual), 0o600)
- require.NoError(t, err)
- }
-
- expected, err := os.ReadFile(expectedFileName)
- require.NoError(t, err)
- require.Equal(t, string(expected), actual)
-
- app.Stop()
- appErr := app.Wait()
- require.NoError(t, appErr)
-
- restOutput, err := io.ReadAll(app.Stdout())
- require.NoError(t, err)
- require.Empty(t, string(restOutput))
-}
-
-func startTestLogsServer(
- t *testing.T, token string, identifier map[string]string, messages [][]byte,
- handleErrCh chan<- string,
-) (string, *httptest.Server) {
- t.Helper()
-
- handler := buildTestLogsHandler(token, identifier, messages, handleErrCh)
- srv := httptest.NewServer(handler)
-
- u, err := url.Parse(srv.URL)
- require.NoError(t, err)
- u.Scheme = "ws"
-
- return u.String(), srv
-}
-
-func readOutputUntilLineOrError(
- t *testing.T, r *lineBuffer, prefix string, wsHandleErrCh <-chan string,
-) string {
- t.Helper()
-
- readStr, readErr := startBackgroundReadUntilLine(r, prefix)
-
- timer := time.NewTimer(5 * time.Second)
- select {
- case <-timer.C:
- require.Fail(t, "read output timed out")
- case errStr := <-wsHandleErrCh:
- require.Failf(t, "ws handler finished with error", errStr)
- case err := <-readErr:
- require.Failf(t, "read finished with error", err.Error())
- case s := <-readStr:
- return s
- }
-
- return ""
-}
-
-func startBackgroundReadUntilLine(r *lineBuffer, prefix string) (<-chan string, <-chan error) {
- readStr := make(chan string, 1)
- readErr := make(chan error, 1)
-
- go func() {
- buf := strings.Builder{}
-
- for {
- s, err := r.ReadLine()
- if err != nil {
- readErr <- err
- return
- }
-
- buf.WriteString(s)
-
- if strings.HasPrefix(s, prefix) {
- readStr <- buf.String()
- return
- }
- }
- }()
-
- return readStr, readErr
-}
-
-//nolint:funlen // because contains a lot of simple logged checks.
-func buildTestLogsHandler(
- token string, identifier map[string]string, messages [][]byte, handleErrCh chan<- string,
-) http.Handler {
- f := func(w http.ResponseWriter, r *http.Request) {
- reqToken := r.URL.Query().Get("token")
- if reqToken != token {
- w.WriteHeader(http.StatusBadRequest)
- handleErrCh <- fmt.Sprintf("unexpected token %q, should be %q", reqToken, token)
- return
- }
-
- upgrader := websocket.Upgrader{}
- c, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- handleErrCh <- fmt.Sprintf("failed to upgrade: %s", err)
- return
- }
- defer c.Close()
-
- msgType, msgBytes, err := c.ReadMessage()
- if err != nil {
- handleErrCh <- fmt.Sprintf("failed to read subscribe message: %s", err)
- return
- }
-
- if msgType != websocket.TextMessage {
- handleErrCh <- fmt.Sprintf("subscribe message should be text type [%d], but [%d]",
- websocket.TextMessage, msgType)
- return
- }
-
- subMsg := struct {
- Command string `json:"command"`
- Identifier string `json:"identifier"`
- }{}
- if err := json.Unmarshal(msgBytes, &subMsg); err != nil {
- handleErrCh <- fmt.Sprintf("failed to unmarshall subsribe message %q: %s", string(msgBytes), err.Error())
- return
- }
-
- if subMsg.Command != "subscribe" {
- handleErrCh <- fmt.Sprintf("this is not subscribe message, but %q", subMsg.Command)
- return
- }
-
- var reqIdentifier map[string]string
- if err := json.Unmarshal([]byte(subMsg.Identifier), &reqIdentifier); err != nil {
- handleErrCh <- fmt.Sprintf("failed to unmarshall subsribe message identifier %q: %s",
- subMsg.Identifier, err.Error())
- return
- }
-
- if !reflect.DeepEqual(identifier, reqIdentifier) {
- handleErrCh <- fmt.Sprintf("subsribe message identifier shoud be equal to %q, but %q",
- identifier, reqIdentifier)
- return
- }
-
- for _, m := range messages {
- if len(m) == 0 {
- continue
- }
- if err := c.WriteMessage(websocket.TextMessage, m); err != nil {
- handleErrCh <- fmt.Sprintf("failed to write message: %s", err.Error())
- return
- }
- }
-
- <-r.Context().Done()
- }
-
- return http.HandlerFunc(f)
-}
diff --git a/internal/app/enaptercli/cmd_rule_engine.go b/internal/app/enaptercli/cmd_rule_engine.go
new file mode 100644
index 0000000..16c0a56
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine.go
@@ -0,0 +1,53 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngine struct {
+ cmdBase
+ siteID string
+}
+
+func buildCmdRuleEngine() *cli.Command {
+ cmd := &cmdRuleEngine{}
+ return &cli.Command{
+ Name: "rule-engine",
+ Usage: "Manage the rule engine",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdRuleEngineGet(),
+ buildCmdRuleEngineSuspend(),
+ buildCmdRuleEngineResume(),
+ buildCmdRuleEngineRule(),
+ },
+ }
+}
+
+func (c *cmdRuleEngine) Flags() []cli.Flag {
+ flags := c.cmdBase.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "site-id",
+ Usage: "site ID",
+ Destination: &c.siteID,
+ })
+}
+
+func (c *cmdRuleEngine) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ return err
+ }
+
+ path, err := url.JoinPath("/sites/", siteID, "/rule_engine", p.Path)
+ if err != nil {
+ return fmt.Errorf("join path: %w", err)
+ }
+
+ p.Path = path
+ return c.cmdBase.doHTTPRequest(ctx, p)
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_get.go b/internal/app/enaptercli/cmd_rule_engine_get.go
new file mode 100644
index 0000000..69a9bf4
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_get.go
@@ -0,0 +1,33 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineGet struct {
+ cmdRuleEngine
+}
+
+func buildCmdRuleEngineGet() *cli.Command {
+ cmd := &cmdRuleEngineGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Retrieve the rule engine",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineGet) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "",
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_resume.go b/internal/app/enaptercli/cmd_rule_engine_resume.go
new file mode 100644
index 0000000..b40b0eb
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_resume.go
@@ -0,0 +1,33 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineResume struct {
+ cmdRuleEngine
+}
+
+func buildCmdRuleEngineResume() *cli.Command {
+ cmd := &cmdRuleEngineResume{}
+ return &cli.Command{
+ Name: "resume",
+ Usage: "Resume execution of rules",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineResume) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/resume",
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule.go b/internal/app/enaptercli/cmd_rule_engine_rule.go
new file mode 100644
index 0000000..7fdfbf0
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule.go
@@ -0,0 +1,52 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+const (
+ ruleRuntimeV1 = "V1"
+ ruleRuntimeV3 = "V3"
+)
+
+type cmdRuleEngineRule struct {
+ cmdRuleEngine
+}
+
+func buildCmdRuleEngineRule() *cli.Command {
+ cmd := &cmdRuleEngineRule{}
+ return &cli.Command{
+ Name: "rule",
+ Usage: "Manage rules",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdRuleEngineRuleCreate(),
+ buildCmdRuleEngineRuleDelete(),
+ buildCmdRuleEngineRuleDisable(),
+ buildCmdRuleEngineRuleEnable(),
+ buildCmdRuleEngineRuleGet(),
+ buildCmdRuleEngineRuleList(),
+ buildCmdRuleEngineRuleUpdate(),
+ buildCmdRuleEngineRuleUpdateScript(),
+ buildCmdRuleEngineRuleLogs(),
+ },
+ }
+}
+
+func (c *cmdRuleEngineRule) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ path, err := url.JoinPath("/rules", p.Path)
+ if err != nil {
+ return fmt.Errorf("join path: %w", err)
+ }
+ p.Path = path
+ return c.cmdRuleEngine.doHTTPRequest(ctx, p)
+}
+
+func (c *cmdRuleEngineRule) validateRuntimeVersion(value string) error {
+ supportedVersions := []string{ruleRuntimeV1, ruleRuntimeV3}
+ return validateFlag("runtime-version", value, supportedVersions)
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_create.go b/internal/app/enaptercli/cmd_rule_engine_rule_create.go
new file mode 100644
index 0000000..d865b06
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_create.go
@@ -0,0 +1,108 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/cliflags"
+)
+
+type cmdRuleEngineRuleCreate struct {
+ cmdRuleEngineRule
+ slug string
+ scriptPath string
+ runtimeVersion string
+ execInterval time.Duration
+ disable bool
+}
+
+func buildCmdRuleEngineRuleCreate() *cli.Command {
+ cmd := &cmdRuleEngineRuleCreate{}
+ return &cli.Command{
+ Name: "create",
+ Usage: "Create a new rule",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleCreate) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "slug",
+ Usage: "Slug for the new rule",
+ Destination: &c.slug,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "script",
+ Usage: "Path to the file containing the script code",
+ Destination: &c.scriptPath,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "runtime-version",
+ Usage: "Version of the runtime to use for the script execution",
+ Destination: &c.runtimeVersion,
+ Value: ruleRuntimeV3,
+ Action: func(_ *cli.Context, v string) error {
+ return c.validateRuntimeVersion(v)
+ },
+ },
+ &cliflags.Duration{
+ DurationFlag: cli.DurationFlag{
+ Name: "exec-interval",
+ Usage: "How frequently to execute the script " +
+ "(compatible only with runtime version 1) in duration format (e.g., 5s, 2m)",
+ Destination: &c.execInterval,
+ },
+ },
+ &cli.BoolFlag{
+ Name: "disable",
+ Usage: "Disable the rule upon creation",
+ Destination: &c.disable,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleCreate) do(ctx context.Context) error {
+ if c.scriptPath == "-" {
+ c.scriptPath = "/dev/stdin"
+ }
+ scriptBytes, err := os.ReadFile(c.scriptPath)
+ if err != nil {
+ return fmt.Errorf("read script code file: %w", err)
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "slug": c.slug,
+ "script": map[string]any{
+ "code": base64.StdEncoding.EncodeToString(scriptBytes),
+ "runtime_version": c.runtimeVersion,
+ "exec_interval": c.execInterval.String(),
+ },
+ "disable": c.disable,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_delete.go b/internal/app/enaptercli/cmd_rule_engine_rule_delete.go
new file mode 100644
index 0000000..ef82b7d
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_delete.go
@@ -0,0 +1,46 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleDelete struct {
+ cmdRuleEngineRule
+ ruleID string
+}
+
+func buildCmdRuleEngineRuleDelete() *cli.Command {
+ cmd := &cmdRuleEngineRuleDelete{}
+ return &cli.Command{
+ Name: "delete",
+ Usage: "Delete a rule",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleDelete) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "rule-id",
+ Usage: "Rule ID or slug",
+ Required: true,
+ Destination: &c.ruleID,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleDelete) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodDelete,
+ Path: "/" + c.ruleID,
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_disable.go b/internal/app/enaptercli/cmd_rule_engine_rule_disable.go
new file mode 100644
index 0000000..a5c76fc
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_disable.go
@@ -0,0 +1,58 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleDisable struct {
+ cmdRuleEngineRule
+ ruleIDs []string
+}
+
+func buildCmdRuleEngineRuleDisable() *cli.Command {
+ cmd := &cmdRuleEngineRuleDisable{}
+ return &cli.Command{
+ Name: "disable",
+ Usage: "Disable one or more rules",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleDisable) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.MultiStringFlag{
+ Target: &cli.StringSliceFlag{
+ Name: "rule-id",
+ Usage: "Rule IDs or slugs",
+ Required: true,
+ },
+ Destination: &c.ruleIDs,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleDisable) do(ctx context.Context) error {
+ body, err := json.Marshal(map[string]any{
+ "rule_ids": c.ruleIDs,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/batch_disable",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_enable.go b/internal/app/enaptercli/cmd_rule_engine_rule_enable.go
new file mode 100644
index 0000000..44e9580
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_enable.go
@@ -0,0 +1,58 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleEnable struct {
+ cmdRuleEngineRule
+ ruleIDs []string
+}
+
+func buildCmdRuleEngineRuleEnable() *cli.Command {
+ cmd := &cmdRuleEngineRuleEnable{}
+ return &cli.Command{
+ Name: "enable",
+ Usage: "Enable one or more rules",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleEnable) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.MultiStringFlag{
+ Target: &cli.StringSliceFlag{
+ Name: "rule-id",
+ Usage: "Rule IDs or slugs",
+ Required: true,
+ },
+ Destination: &c.ruleIDs,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleEnable) do(ctx context.Context) error {
+ body, err := json.Marshal(map[string]any{
+ "rule_ids": c.ruleIDs,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/batch_enable",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_get.go b/internal/app/enaptercli/cmd_rule_engine_rule_get.go
new file mode 100644
index 0000000..abf31f1
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_get.go
@@ -0,0 +1,45 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleGet struct {
+ cmdRuleEngineRule
+ ruleID string
+}
+
+func buildCmdRuleEngineRuleGet() *cli.Command {
+ cmd := &cmdRuleEngineRuleGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Retrieve a rule",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleGet) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "rule-id",
+ Usage: "Rule ID or slug",
+ Destination: &c.ruleID,
+ Required: true,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleGet) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.ruleID,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_list.go b/internal/app/enaptercli/cmd_rule_engine_rule_list.go
new file mode 100644
index 0000000..cb1de8e
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_list.go
@@ -0,0 +1,33 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleList struct {
+ cmdRuleEngineRule
+}
+
+func buildCmdRuleEngineRuleList() *cli.Command {
+ cmd := &cmdRuleEngineRuleList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List rules",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleList) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "",
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_logs.go b/internal/app/enaptercli/cmd_rule_engine_rule_logs.go
new file mode 100644
index 0000000..6e328ee
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_logs.go
@@ -0,0 +1,72 @@
+package enaptercli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleLogs struct {
+ cmdRuleEngineRule
+ ruleID string
+ follow bool
+}
+
+func buildCmdRuleEngineRuleLogs() *cli.Command {
+ cmd := &cmdRuleEngineRuleLogs{}
+ return &cli.Command{
+ Name: "logs",
+ Usage: "Show rule logs",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleLogs) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "rule-id",
+ Usage: "rule ID",
+ Destination: &c.ruleID,
+ Required: true,
+ },
+ &cli.BoolFlag{
+ Name: "follow",
+ Aliases: []string{"f"},
+ Usage: "follow the log output",
+ Destination: &c.follow,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleLogs) do(cliCtx *cli.Context) error {
+ if !c.follow {
+ return cli.Exit("Currently, only follow mode (--follow) is supported.", 1)
+ }
+
+ path := fmt.Sprintf("/site/rule_engine/rules/%s/logs", c.ruleID)
+
+ return c.runWebSocket(cliCtx.Context, runWebSocketParams{
+ Path: path,
+ RespProcessor: func(r io.Reader) error {
+ var msg struct {
+ Timestamp int64 `json:"timestamp"`
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ }
+ if err := json.NewDecoder(r).Decode(&msg); err != nil {
+ return fmt.Errorf("parse payload: %w", err)
+ }
+ ts := time.Unix(msg.Timestamp, 0).Format(time.RFC3339)
+ fmt.Fprintf(c.writer, "%s [%s] %s\n", ts, msg.Severity, msg.Message)
+ return nil
+ },
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_update.go b/internal/app/enaptercli/cmd_rule_engine_rule_update.go
new file mode 100644
index 0000000..9009ec6
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_update.go
@@ -0,0 +1,66 @@
+package enaptercli
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineRuleUpdate struct {
+ cmdRuleEngineRule
+ ruleID string
+ slug string
+}
+
+func buildCmdRuleEngineRuleUpdate() *cli.Command {
+ cmd := &cmdRuleEngineRuleUpdate{}
+ return &cli.Command{
+ Name: "update",
+ Usage: "Update a rule",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleUpdate) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "rule-id",
+ Usage: "Rule ID or slug to update",
+ Destination: &c.ruleID,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "slug",
+ Usage: "A new rule slug",
+ Destination: &c.slug,
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleUpdate) do(cliCtx *cli.Context) error {
+ payload := make(map[string]any)
+
+ if cliCtx.IsSet("slug") {
+ payload["slug"] = c.slug
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(cliCtx.Context, doHTTPRequestParams{
+ Method: http.MethodPatch,
+ Path: "/" + c.ruleID,
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_rule_update_script.go b/internal/app/enaptercli/cmd_rule_engine_rule_update_script.go
new file mode 100644
index 0000000..9e1ee24
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_update_script.go
@@ -0,0 +1,100 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/enapter/enapter-cli/internal/app/cliflags"
+)
+
+type cmdRuleEngineRuleUpdateScript struct {
+ cmdRuleEngineRule
+ ruleID string
+ scriptPath string
+ runtimeVersion string
+ execInterval time.Duration
+}
+
+func buildCmdRuleEngineRuleUpdateScript() *cli.Command {
+ cmd := &cmdRuleEngineRuleUpdateScript{}
+ return &cli.Command{
+ Name: "update-script",
+ Usage: "Update the script of a rule",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleUpdateScript) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &cli.StringFlag{
+ Name: "rule-id",
+ Usage: "Rule ID or slug to update",
+ Destination: &c.ruleID,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "script",
+ Usage: "Path to a file containing the script code",
+ Destination: &c.scriptPath,
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "runtime-version",
+ Usage: "Version of the runtime to use for the script execution",
+ Destination: &c.runtimeVersion,
+ Value: ruleRuntimeV3,
+ Action: func(_ *cli.Context, v string) error {
+ return c.validateRuntimeVersion(v)
+ },
+ },
+ &cliflags.Duration{
+ DurationFlag: cli.DurationFlag{
+ Name: "exec-interval",
+ Usage: "How frequently to execute the script " +
+ "(compatible only with runtime version 1) in duration format (e.g., 5s, 2m)",
+ Destination: &c.execInterval,
+ },
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleUpdateScript) do(ctx context.Context) error {
+ if c.scriptPath == "-" {
+ c.scriptPath = "/dev/stdin"
+ }
+ scriptBytes, err := os.ReadFile(c.scriptPath)
+ if err != nil {
+ return fmt.Errorf("read script file: %w", err)
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "script": map[string]any{
+ "code": base64.StdEncoding.EncodeToString(scriptBytes),
+ "runtime_version": c.runtimeVersion,
+ "exec_interval": c.execInterval.String(),
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/" + c.ruleID + "/update_script",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rule_engine_suspend.go b/internal/app/enaptercli/cmd_rule_engine_suspend.go
new file mode 100644
index 0000000..5637e48
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_suspend.go
@@ -0,0 +1,33 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdRuleEngineSuspend struct {
+ cmdRuleEngine
+}
+
+func buildCmdRuleEngineSuspend() *cli.Command {
+ cmd := &cmdRuleEngineSuspend{}
+ return &cli.Command{
+ Name: "suspend",
+ Usage: "Suspend execution of rules",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdRuleEngineSuspend) do(ctx context.Context) error {
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/suspend",
+ })
+}
diff --git a/internal/app/enaptercli/cmd_rules.go b/internal/app/enaptercli/cmd_rules.go
deleted file mode 100644
index c6a465e..0000000
--- a/internal/app/enaptercli/cmd_rules.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package enaptercli
-
-import "github.com/urfave/cli/v2"
-
-type cmdRules struct {
- cmdBase
- ruleID string
-}
-
-func buildCmdRules() *cli.Command {
- return &cli.Command{
- Name: "rules",
- Usage: "Rules information and management commands.",
- Subcommands: []*cli.Command{
- buildCmdRulesUpdate(),
- buildCmdRulesLogs(),
- },
- }
-}
-
-func (c *cmdRules) Flags() []cli.Flag {
- flags := c.cmdBase.Flags()
- flags = append(flags, &cli.StringFlag{
- Name: "rule-id",
- Usage: "Rule ID; can be obtained in cloud.enapter.com",
- Required: true,
- Destination: &c.ruleID,
- })
- return flags
-}
diff --git a/internal/app/enaptercli/cmd_rules_logs.go b/internal/app/enaptercli/cmd_rules_logs.go
deleted file mode 100644
index a02059e..0000000
--- a/internal/app/enaptercli/cmd_rules_logs.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-type cmdRulesLogs struct {
- cmdRules
-}
-
-func buildCmdRulesLogs() *cli.Command {
- cmd := &cmdRulesLogs{}
-
- return &cli.Command{
- Name: "logs",
- Usage: "Stream logs from a rule",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdRulesLogs) run(ctx context.Context, version string) error {
- writer := func(topic, msg string) {
- fmt.Fprintf(c.writer, "[%s] %s\n", topic, msg)
- }
-
- streamer, err := cloudapi.NewRuleLogsWriter(c.websocketsURL, c.token,
- version, c.ruleID, writer)
- if err != nil {
- return fmt.Errorf("create streamer: %w", err)
- }
-
- return streamer.Run(ctx)
-}
diff --git a/internal/app/enaptercli/cmd_rules_logs_test.go b/internal/app/enaptercli/cmd_rules_logs_test.go
deleted file mode 100644
index 41e9339..0000000
--- a/internal/app/enaptercli/cmd_rules_logs_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//nolint:dupl // not a duplicate of `devices logs` command tests
-package enaptercli_test
-
-import (
- "strings"
- "testing"
-)
-
-func TestRuleLogs(t *testing.T) {
- t.Run("simple", func(t *testing.T) {
- inputFileName := "testdata/rules_logs/simple/input"
- untilLinePrefix := "[info]"
- expectedFileName := "testdata/rules_logs/simple/output"
- testRuleLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("invalid token", func(t *testing.T) {
- inputFileName := "testdata/rules_logs/disconnect/invalid_token/input"
- untilLinePrefix := "[connection]"
- expectedFileName := "testdata/rules_logs/disconnect/invalid_token/output"
- testRuleLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("rule not found", func(t *testing.T) {
- inputFileName := "testdata/rules_logs/disconnect/rule_not_found/input"
- untilLinePrefix := "[connection] disconnected"
- expectedFileName := "testdata/rules_logs/disconnect/rule_not_found/output"
- testRuleLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-}
-
-func testRuleLogs(t *testing.T, inputFileName, untilLinePrefix, expectedFileName string) {
- const hardwareID = "SIM-RULE"
-
- identifier := map[string]string{"channel": "RuleChannel", "rule_id": hardwareID}
-
- command := strings.Split("enapter rules logs", " ")
- command = append(command, "--rule-id", hardwareID)
-
- testLogsCommand(t, inputFileName, untilLinePrefix, expectedFileName, identifier, command)
-}
diff --git a/internal/app/enaptercli/cmd_rules_update.go b/internal/app/enaptercli/cmd_rules_update.go
deleted file mode 100644
index b72a705..0000000
--- a/internal/app/enaptercli/cmd_rules_update.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
- "net/http"
- "os"
- "time"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-const ruleUpdateDefaultTimeout = 30 * time.Second
-
-type cmdRulesUpdate struct {
- cmdRules
- path string
- executionInterval int
- stdlibVersion string
- timeout time.Duration
-}
-
-func buildCmdRulesUpdate() *cli.Command {
- cmd := &cmdRulesUpdate{}
-
- return &cli.Command{
- Name: "update",
- Usage: "Update rule.",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdRulesUpdate) Flags() []cli.Flag {
- flags := c.cmdRules.Flags()
- flags = append(flags,
- &cli.StringFlag{
- Name: "rule-path",
- Usage: "Path to file with rule Lua code",
- Destination: &c.path,
- },
- &cli.IntFlag{
- Name: "execution-interval",
- Usage: "Rule execution interval in milliseconds",
- DefaultText: "chosen by the server",
- Destination: &c.executionInterval,
- },
- &cli.StringFlag{
- Name: "stdlib-version",
- Usage: "Version of standard library used by the rule",
- DefaultText: "chosen by the server",
- Destination: &c.stdlibVersion,
- },
- &cli.DurationFlag{
- Name: "timeout",
- Usage: "Time to wait for rule update",
- Destination: &c.timeout,
- Value: ruleUpdateDefaultTimeout,
- },
- )
- return flags
-}
-
-func (c *cmdRulesUpdate) run(ctx context.Context, version string) error {
- if c.timeout != 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, c.timeout)
- defer cancel()
- }
-
- luaCode, err := os.ReadFile(c.path)
- if err != nil {
- return fmt.Errorf("read rule file: %w", err)
- }
-
- transport := cloudapi.NewCredentialsTransport(http.DefaultTransport, c.token, version)
- transport = cloudapi.NewCLIMessageWriterTransport(transport, &onceWriter{w: c.writer})
- client := cloudapi.NewClientWithURL(&http.Client{Transport: transport}, c.graphqlURL)
-
- input := cloudapi.UpdateRuleInput{
- RuleID: c.ruleID,
- LuaCode: string(luaCode),
- StdlibVersion: c.stdlibVersion,
- ExecutionInterval: c.executionInterval,
- }
-
- updateData, updateErrors, err := client.UpdateRule(ctx, input)
- if err != nil {
- return fmt.Errorf("do update: %w", err)
- }
-
- if len(updateErrors) != 0 {
- for _, e := range updateErrors {
- fmt.Fprintln(c.writer, "[ERROR]", e.Message)
- }
- return errFinishedWithError
- }
-
- fmt.Fprintln(c.writer, updateData.Message)
- return nil
-}
diff --git a/internal/app/enaptercli/cmd_rules_update_test.go b/internal/app/enaptercli/cmd_rules_update_test.go
deleted file mode 100644
index 0a7ee9c..0000000
--- a/internal/app/enaptercli/cmd_rules_update_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package enaptercli_test
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-func TestRulesUpdate(t *testing.T) {
- testdataDir := "testdata/rules_update"
- dirs, err := os.ReadDir(testdataDir)
- require.NoError(t, err)
-
- for _, dir := range dirs {
- if !dir.IsDir() {
- continue
- }
-
- dir := dir
- t.Run(dir.Name(), func(t *testing.T) {
- testRulesUpdate(t, filepath.Join(testdataDir, dir.Name()))
- })
- }
-}
-
-func TestRulesUpdateWrongFilePath(t *testing.T) {
- args := strings.Split("enapter rules update --token token --rule-id ruleID "+
- "--gql-api-url apiURL --rule-path wrong", " ")
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
- require.EqualError(t, appErr, "read rule file: open wrong: no such file or directory")
-}
-
-type rulesUpdateTestSettings struct {
- RuleID string `json:"rule_id"`
- RulePath string `json:"rule_path"`
- Token string `json:"-"`
-}
-
-func (s *rulesUpdateTestSettings) Fill(t *testing.T, dir string) {
- settingsBytes, err := os.ReadFile(filepath.Join(dir, "settings.json"))
- require.NoError(t, err)
- require.NoError(t, json.Unmarshal(settingsBytes, s))
-
- s.RulePath = filepath.Join(dir, s.RulePath)
- s.Token = faker.Word()
-}
-
-func testRulesUpdate(t *testing.T, dir string) {
- var settings rulesUpdateTestSettings
- settings.Fill(t, dir)
-
- reqs := byteSliceSliceFromFile(t, filepath.Join(dir, "requests"))
- resps := byteSliceSliceFromFile(t, filepath.Join(dir, "responses"))
-
- srv := startTestServer(reqs, resps, "")
- defer srv.Close()
-
- args := strings.Split("enapter rules update", " ")
- args = append(args,
- "--token", settings.Token,
- "--rule-id", settings.RuleID,
- "--rule-path", settings.RulePath,
- "--gql-api-url", srv.URL)
-
- checkTestAppOutput(t, dir, args, reqs)
-}
diff --git a/internal/app/enaptercli/cmd_site.go b/internal/app/enaptercli/cmd_site.go
new file mode 100644
index 0000000..75fc336
--- /dev/null
+++ b/internal/app/enaptercli/cmd_site.go
@@ -0,0 +1,35 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdSite struct {
+ cmdBase
+}
+
+func buildCmdSite() *cli.Command {
+ cmd := &cmdSite{}
+ return &cli.Command{
+ Name: "site",
+ Usage: "Manage sites",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Subcommands: []*cli.Command{
+ buildCmdSiteList(),
+ buildCmdSiteGet(),
+ },
+ }
+}
+
+func (c *cmdSite) doHTTPRequest(ctx context.Context, p doHTTPRequestParams) error {
+ path, err := url.JoinPath("/sites", p.Path)
+ if err != nil {
+ return fmt.Errorf("join path: %w", err)
+ }
+ p.Path = path
+ return c.cmdBase.doHTTPRequest(ctx, p)
+}
diff --git a/internal/app/enaptercli/cmd_site_get.go b/internal/app/enaptercli/cmd_site_get.go
new file mode 100644
index 0000000..929904c
--- /dev/null
+++ b/internal/app/enaptercli/cmd_site_get.go
@@ -0,0 +1,47 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdSiteGet struct {
+ cmdSite
+ siteID string
+}
+
+func buildCmdSiteGet() *cli.Command {
+ cmd := &cmdSiteGet{}
+ return &cli.Command{
+ Name: "get",
+ Usage: "Get a site",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdSiteGet) Flags() []cli.Flag {
+ flags := c.cmdSite.Flags()
+ return append(flags, &cli.StringFlag{
+ Name: "site-id",
+ Usage: "site ID",
+ Destination: &c.siteID,
+ })
+}
+
+func (c *cmdSiteGet) do(ctx context.Context) error {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ return err
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + siteID,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_site_list.go b/internal/app/enaptercli/cmd_site_list.go
new file mode 100644
index 0000000..c43854f
--- /dev/null
+++ b/internal/app/enaptercli/cmd_site_list.go
@@ -0,0 +1,88 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdSiteList struct {
+ cmdSite
+ mySites bool
+ limit int
+}
+
+func buildCmdSiteList() *cli.Command {
+ cmd := &cmdSiteList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List user sites",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdSiteList) Flags() []cli.Flag {
+ flags := c.cmdSite.Flags()
+ return append(flags, &cli.BoolFlag{
+ Name: "my-sites",
+ Usage: "returns only sites where user is owner or installer",
+ Destination: &c.mySites,
+ }, &cli.IntFlag{
+ Name: "limit",
+ Usage: "maximum number of sites to retrieve",
+ Destination: &c.limit,
+ DefaultText: "retrieves all",
+ })
+}
+
+func (c *cmdSiteList) do(ctx context.Context) error {
+ if siteID, _ := c.chooseSiteID(""); siteID != "" {
+ fmt.Fprintln(c.errWriter, "WARNING: trying to get sites list when site ID "+
+ "is set for current connection, result will contain only one site.")
+
+ var resp struct {
+ Site json.RawMessage `json:"site"`
+ }
+ if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/" + c.siteID,
+ RespProcessor: func(r *http.Response) error {
+ return json.NewDecoder(r.Body).Decode(&resp)
+ },
+ }); err != nil {
+ return err
+ }
+
+ return json.NewEncoder(c.writer).Encode(struct {
+ Sites []json.RawMessage `json:"sites"`
+ TotalCount int `json:"total_count"`
+ }{
+ Sites: []json.RawMessage{resp.Site},
+ TotalCount: 1,
+ })
+ }
+
+ doPaginateRequestParams := paginateHTTPRequestParams{
+ ObjectName: "sites",
+ Limit: c.limit,
+ DoFn: c.doHTTPRequest,
+ BaseParams: doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "",
+ },
+ }
+
+ if c.mySites {
+ doPaginateRequestParams.BaseParams.Path = "/users/me/sites"
+ }
+
+ return c.doPaginateRequest(ctx, doPaginateRequestParams)
+}
diff --git a/internal/app/enaptercli/cmd_test.go b/internal/app/enaptercli/cmd_test.go
deleted file mode 100644
index fde7025..0000000
--- a/internal/app/enaptercli/cmd_test.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package enaptercli_test
-
-import (
- "bytes"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-type byteSliceSlice struct {
- lines [][]byte
-}
-
-func byteSliceSliceFromFile(t *testing.T, path string) *byteSliceSlice {
- f, err := os.ReadFile(path)
- require.NoError(t, err)
- lines := bytes.Split(f, []byte{'\n'})
-
- n := 0
- for _, line := range lines {
- if len(line) > 0 {
- lines[n] = line
- n++
- }
- }
-
- return &byteSliceSlice{lines: lines[:n]}
-}
-
-func (b *byteSliceSlice) Next() []byte {
- for i, s := range b.lines {
- b.lines = b.lines[i+1:]
- return s
- }
- return nil
-}
-
-func (b *byteSliceSlice) Append(d []byte) {
- b.lines = append(b.lines, d)
-}
-
-func (b *byteSliceSlice) Buffer() [][]byte {
- return b.lines
-}
-
-func (b *byteSliceSlice) Clear() {
- b.lines = nil
-}
-
-func startTestServer(reqs, resps *byteSliceSlice, cliMessage string) *httptest.Server {
- if update {
- reqs.Clear()
- }
-
- handler := func(w http.ResponseWriter, r *http.Request) {
- resp := resps.Next()
- if resp == nil {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("to much requests for test (not enough responses)"))
- return
- }
-
- var req []byte
- if !update {
- req = reqs.Next()
- if len(req) == 0 {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("to much requests for test (not enough requests)"))
- return
- }
- }
-
- if cliMessage != "" {
- w.Header().Set("X-ENAPTER-CLI-MESSAGE", cliMessage)
- }
-
- reqBody, err := io.ReadAll(r.Body)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte("failed to read request"))
- return
- }
-
- if update {
- reqs.Append(reqBody)
- } else {
- reqBody := bytes.TrimRight(reqBody, "\n")
- if !bytes.Equal(reqBody, req) {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("unexpected request\nActual\n"))
- _, _ = w.Write(reqBody)
- _, _ = w.Write([]byte("\nExpected\n"))
- _, _ = w.Write(req)
- return
- }
- }
-
- _, _ = w.Write(resp)
- }
-
- return httptest.NewServer(http.HandlerFunc(handler))
-}
-
-func checkTestAppOutput(t *testing.T, basePath string, args []string, requests *byteSliceSlice) {
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
-
- actual, err := io.ReadAll(app.Stdout())
- require.NoError(t, err)
-
- if appErr != nil {
- actual = append(actual, []byte("app exit with error: "+appErr.Error()+"\n")...)
- }
-
- expectedFileName := filepath.Join(basePath, "output")
- if update {
- err := os.WriteFile(expectedFileName, actual, 0o600)
- require.NoError(t, err)
-
- requestsFileName := filepath.Join(basePath, "requests")
- requestsBytes := bytes.Join(requests.Buffer(), []byte{'\n'})
- err = os.WriteFile(requestsFileName, requestsBytes, 0o600)
- require.NoError(t, err)
- }
-
- expected, err := os.ReadFile(expectedFileName)
- require.NoError(t, err)
-
- require.Equal(t, string(expected), string(actual))
-}
diff --git a/internal/app/enaptercli/content_types.go b/internal/app/enaptercli/content_types.go
new file mode 100644
index 0000000..5431e71
--- /dev/null
+++ b/internal/app/enaptercli/content_types.go
@@ -0,0 +1,5 @@
+package enaptercli
+
+const (
+ contentTypeJSON = "application/json"
+)
diff --git a/internal/app/enaptercli/errors.go b/internal/app/enaptercli/errors.go
index e2d3fb3..44d17b2 100644
--- a/internal/app/enaptercli/errors.go
+++ b/internal/app/enaptercli/errors.go
@@ -3,7 +3,9 @@ package enaptercli
import "errors"
var (
- errFinishedWithError = errors.New("request execution failed")
- errAPITokenMissed = errors.New("API token missing. Set it up using environment " +
- "variable ENAPTER_API_TOKEN")
+ errSiteIDMismatch = errors.New("passed site-ID must match the site-ID of the current connection")
+ errSiteIDMissing = errors.New("site ID is required")
+ errUnsupportedFlagValue = errors.New("unsupported flag value")
+ errOnlyOneBlueprinFlag = errors.New("only one of --blueprint-id or --blueprint-path can be specified")
+ errMissedBlueprintFlag = errors.New("one of --blueprint-id or --blueprint-path must be specified")
)
diff --git a/internal/app/enaptercli/execute.go b/internal/app/enaptercli/execute.go
index 09df97a..8010ce4 100644
--- a/internal/app/enaptercli/execute.go
+++ b/internal/app/enaptercli/execute.go
@@ -3,10 +3,10 @@ package enaptercli
import (
"archive/zip"
"bytes"
+ "fmt"
"io"
+ "io/fs"
"os"
- "path/filepath"
- "strings"
"github.com/urfave/cli/v2"
)
@@ -15,55 +15,59 @@ import (
func NewApp() *cli.App {
app := cli.NewApp()
- app.Usage = "Command line interface for Enapter services."
- app.Description = "Enapter CLI requires access token for authentication. " +
- "The token can be obtained in your Enapter Cloud account settings.\n\n" +
- "Configure API token using ENAPTER_API_TOKEN environment variable or using --token global option."
+ app.Usage = "Command Line Interface (CLI) for Enapter services."
+ app.Description = "The Enapter CLI requires an access token for authentication. " +
+ "You can obtain the token in your Enapter Cloud account settings."
+ app.CustomAppHelpTemplate = cli.AppHelpTemplate + enapterAPIEnvVarsHelp
app.Commands = []*cli.Command{
- buildCmdDevices(),
- buildCmdRules(),
+ buildCmdSite(),
+ buildCmdDevice(),
+ buildCmdBlueprint(),
+ buildCmdRuleEngine(),
+ buildCmdConnection(),
}
return app
}
func zipDir(path string) ([]byte, error) {
- buf := &bytes.Buffer{}
- myZip := zip.NewWriter(buf)
+ fsys := os.DirFS(path)
- path = strings.TrimPrefix(path, "./")
+ buf := &bytes.Buffer{}
+ zw := zip.NewWriter(buf)
- err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
+ err := fs.WalkDir(fsys, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
- if info.IsDir() {
+ if entry.IsDir() {
return nil
}
- relPath := strings.TrimPrefix(filePath, path)
- relPath = strings.TrimPrefix(relPath, "/")
- zipFile, err := myZip.Create(relPath)
+
+ f, err := fsys.Open(path)
if err != nil {
- return err
+ return fmt.Errorf("open: %w", err)
}
- fsFile, err := os.Open(filePath)
+ defer f.Close()
+
+ zf, err := zw.Create(path)
if err != nil {
- return err
+ return fmt.Errorf("create: %w", err)
}
- _, err = io.Copy(zipFile, fsFile)
- if err != nil {
- return err
+
+ if _, err = io.Copy(zf, f); err != nil {
+ return fmt.Errorf("copy: %w", err)
}
return nil
})
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("walk dir: %w", err)
}
- if err := myZip.Close(); err != nil {
- return nil, err
+ if err := zw.Close(); err != nil {
+ return nil, fmt.Errorf("close zip: %w", err)
}
- return buf.Bytes(), err
+ return buf.Bytes(), nil
}
diff --git a/internal/app/enaptercli/execute_test.go b/internal/app/enaptercli/execute_test.go
index 0feda1e..9227850 100644
--- a/internal/app/enaptercli/execute_test.go
+++ b/internal/app/enaptercli/execute_test.go
@@ -1,28 +1,35 @@
package enaptercli_test
import (
+ "bytes"
+ "encoding/json"
"io"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "text/template"
"github.com/stretchr/testify/require"
)
+const testToken = "enapter_api_test_token"
+
func TestHelpMessages(t *testing.T) {
files, err := os.ReadDir("testdata/helps")
require.NoError(t, err)
for _, fi := range files {
- fi := fi
t.Run(fi.Name(), func(t *testing.T) {
args := strings.Split(fi.Name(), " ")
args = append(args, "-h")
app := startTestApp(args...)
appErr := app.Wait()
- actual, err := io.ReadAll(app.Stdout())
+ actual, err := io.ReadAll(app.Output())
require.NoError(t, err)
if appErr != nil {
@@ -33,12 +40,148 @@ func TestHelpMessages(t *testing.T) {
if update {
err := os.WriteFile(exepctedFileName, actual, 0o600)
require.NoError(t, err)
+ } else {
+ require.Equal(t, readFileToString(t, exepctedFileName), string(actual))
}
+ })
+ }
+}
- expected, err := os.ReadFile(exepctedFileName)
- require.NoError(t, err)
+func TestHTTPReqResp(t *testing.T) {
+ const testdataPath = "testdata/http_req_resp"
+ tests, err := os.ReadDir(testdataPath)
+ require.NoError(t, err)
- require.Equal(t, string(expected), string(actual))
+ for _, tc := range tests {
+ t.Run(tc.Name(), func(t *testing.T) {
+ path := filepath.Join(testdataPath, tc.Name())
+ testExecute(t, path)
})
}
}
+
+func testExecute(t *testing.T, path string) {
+ srv := newTestServer(t, path)
+
+ cmd := executeTmpl(t, filepath.Join(path, "cmd.tmpl"), struct {
+ Token string
+ URL string
+ }{
+ Token: testToken,
+ URL: srv.URL,
+ })
+
+ t.Setenv("ENAPTER3_CONFIG", t.TempDir())
+ output := executeCommands(t, cmd)
+
+ exepctedOutFileName := filepath.Join(path, "out")
+ if update {
+ err := os.WriteFile(exepctedOutFileName, output, 0o600)
+ require.NoError(t, err)
+ } else {
+ expected := readFileToString(t, exepctedOutFileName)
+ require.Equal(t, expected, string(output))
+ }
+}
+
+func newTestServer(t *testing.T, path string) *httptest.Server {
+ t.Helper()
+
+ reqCount := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ reqPath := filepath.Join(path, "req_"+strconv.Itoa(reqCount))
+ respPath := filepath.Join(path, "resp_"+strconv.Itoa(reqCount))
+
+ info := struct {
+ Method string
+ URL string
+ Header http.Header
+ Body string
+ }{
+ Method: r.Method,
+ URL: r.URL.String(),
+ Header: r.Header,
+ Body: readBodyAsString(t, r.Body),
+ }
+ if update {
+ err := os.WriteFile(reqPath, shouldMarshalIndent(t, info), 0o600)
+ require.NoError(t, err)
+ } else {
+ expected := readFileToString(t, reqPath)
+ actual := string(shouldMarshalIndent(t, info))
+ require.Equal(t, expected, actual)
+ }
+
+ resp := shouldReadFile(t, respPath)
+ _, _ = w.Write(resp)
+
+ reqCount++
+ }))
+ t.Cleanup(func() { srv.Close() })
+
+ return srv
+}
+
+func executeCommands(t *testing.T, cmd string) []byte {
+ t.Helper()
+
+ var output []byte
+ for cmd := range strings.Lines(cmd) {
+ cmd := strings.Trim(cmd, "\n")
+ args := strings.Split(cmd, " ")
+
+ app := startTestApp(args...)
+ appErr := app.Wait()
+
+ out, err := io.ReadAll(app.Output())
+ require.NoError(t, err)
+
+ output = append(output, out...)
+ if appErr != nil {
+ output = append(output, []byte("app exit with error: "+appErr.Error()+"\n")...)
+ break
+ }
+ }
+ return output
+}
+
+func executeTmpl(t *testing.T, tmplFilePath string, tmplParams interface{}) string {
+ t.Helper()
+ tmplData := readFileToString(t, tmplFilePath)
+ tmplData = strings.TrimRight(tmplData, " \n\t")
+
+ tmpl := template.New(tmplFilePath)
+ tmpl, err := tmpl.Parse(tmplData)
+ require.NoError(t, err)
+
+ out := &bytes.Buffer{}
+ require.NoError(t, tmpl.Execute(out, tmplParams))
+
+ return out.String()
+}
+
+func readFileToString(t *testing.T, path string) string {
+ t.Helper()
+ return string(shouldReadFile(t, path))
+}
+
+func shouldReadFile(t *testing.T, path string) []byte {
+ t.Helper()
+ d, err := os.ReadFile(path)
+ require.NoError(t, err)
+ return d
+}
+
+func readBodyAsString(t *testing.T, r io.Reader) string {
+ t.Helper()
+ d, err := io.ReadAll(r)
+ require.NoError(t, err)
+ return string(d)
+}
+
+func shouldMarshalIndent(t *testing.T, v interface{}) []byte {
+ t.Helper()
+ d, err := json.MarshalIndent(v, "", " ")
+ require.NoError(t, err)
+ return d
+}
diff --git a/internal/app/enaptercli/once_writer.go b/internal/app/enaptercli/once_writer.go
deleted file mode 100644
index 4ae3a3e..0000000
--- a/internal/app/enaptercli/once_writer.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package enaptercli
-
-import (
- "io"
- "sync"
-)
-
-type onceWriter struct {
- once sync.Once
- w io.Writer
-}
-
-func (w *onceWriter) Write(p []byte) (int, error) {
- n := len(p)
- var err error
- w.once.Do(func() {
- n, err = w.w.Write(p)
- })
- return n, err
-}
diff --git a/internal/app/enaptercli/testdata/blueprints/bp.zip b/internal/app/enaptercli/testdata/blueprints/bp.zip
new file mode 100644
index 0000000..5150cb7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/bp.zip
@@ -0,0 +1 @@
+blueprint.zip
diff --git a/internal/app/enaptercli/testdata/blueprints/simple/firmware.lua b/internal/app/enaptercli/testdata/blueprints/simple/firmware.lua
new file mode 100644
index 0000000..aa6fd65
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/simple/firmware.lua
@@ -0,0 +1 @@
+enapter.log("Hello from firmware.lua")
diff --git a/internal/app/enaptercli/testdata/blueprints/simple/manifest.yml b/internal/app/enaptercli/testdata/blueprints/simple/manifest.yml
new file mode 100644
index 0000000..fc8f08a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/simple/manifest.yml
@@ -0,0 +1,7 @@
+blueprint_spec: device/3.0
+display_name: Simple Lua
+
+runtime:
+ type: lua
+ options:
+ file: firmware.lua
diff --git a/internal/app/enaptercli/testdata/device_execute/error/output b/internal/app/enaptercli/testdata/device_execute/error/output
deleted file mode 100644
index a0772af..0000000
--- a/internal/app/enaptercli/testdata/device_execute/error/output
+++ /dev/null
@@ -1 +0,0 @@
-app exit with error: forbidden: Access denied.
diff --git a/internal/app/enaptercli/testdata/device_execute/error/responses b/internal/app/enaptercli/testdata/device_execute/error/responses
deleted file mode 100644
index c1bed99..0000000
--- a/internal/app/enaptercli/testdata/device_execute/error/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"errors":[{"code":"forbidden","message":"Access denied."}]}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/device_execute/progress/output b/internal/app/enaptercli/testdata/device_execute/progress/output
deleted file mode 100644
index a267617..0000000
--- a/internal/app/enaptercli/testdata/device_execute/progress/output
+++ /dev/null
@@ -1,3 +0,0 @@
-{"state":"started"}
-{"state":"device_in_progress","payload":{"progress":50}}
-{"state":"succeeded"}
diff --git a/internal/app/enaptercli/testdata/device_execute/progress/responses b/internal/app/enaptercli/testdata/device_execute/progress/responses
deleted file mode 100644
index dbb2c43..0000000
--- a/internal/app/enaptercli/testdata/device_execute/progress/responses
+++ /dev/null
@@ -1,3 +0,0 @@
-{"state":"started"}
-{"state":"device_in_progress","payload":{"progress":50}}
-{"state":"succeeded"}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/device_execute/simple/output b/internal/app/enaptercli/testdata/device_execute/simple/output
deleted file mode 100644
index 7d416b3..0000000
--- a/internal/app/enaptercli/testdata/device_execute/simple/output
+++ /dev/null
@@ -1 +0,0 @@
-{"state":"started"}
diff --git a/internal/app/enaptercli/testdata/device_execute/simple/responses b/internal/app/enaptercli/testdata/device_execute/simple/responses
deleted file mode 100644
index 78d59d2..0000000
--- a/internal/app/enaptercli/testdata/device_execute/simple/responses
+++ /dev/null
@@ -1,2 +0,0 @@
-{"state":"started"}
-{"state":"succeeded"}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/input b/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/input
deleted file mode 100644
index 20dbe46..0000000
--- a/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/input
+++ /dev/null
@@ -1,5 +0,0 @@
-{"type":"welcome"}
-
-{"identifier":"{\"channel\":\"DeviceChannel\",\"hardware_id\":\"SIM-WTM\"}","type":"message","message":{"topic":"error","payload":"Device not found"}}
-
-{"identifier":"{\"channel\":\"DeviceChannel\",\"hardware_id\":\"SIM-WTM\"}","type":"reject_subscription"}
diff --git a/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/output b/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/output
deleted file mode 100644
index cae285f..0000000
--- a/internal/app/enaptercli/testdata/device_logs/disconnect/device_not_found/output
+++ /dev/null
@@ -1,3 +0,0 @@
-[connection] welcome
-[error] Device not found
-[connection] disconnected
diff --git a/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/input b/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/input
deleted file mode 100644
index 37f6889..0000000
--- a/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/input
+++ /dev/null
@@ -1 +0,0 @@
-{"type":"disconnect","reason":"unauthorized","reconnect":false}
diff --git a/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/output b/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/output
deleted file mode 100644
index 1c44604..0000000
--- a/internal/app/enaptercli/testdata/device_logs/disconnect/invalid_token/output
+++ /dev/null
@@ -1 +0,0 @@
-[connection] disconnected with reason: unauthorized
diff --git a/internal/app/enaptercli/testdata/device_logs/simple/input b/internal/app/enaptercli/testdata/device_logs/simple/input
deleted file mode 100644
index 63189d3..0000000
--- a/internal/app/enaptercli/testdata/device_logs/simple/input
+++ /dev/null
@@ -1,16 +0,0 @@
-{"type":"welcome"}
-{"identifier":"{\"channel\":\"DeviceChannel\",\"hardware_id\":\"SIM-WTM\"}","type":"confirm_subscription"}
-
-{"type":"message","identifier":"{\"channel\":\"DeviceChannel\",\"hardware_id\":\"SIM-WTM\"}","message":{"topic":"register","payload":"{\"timestamp\":1606485742,\"fw_ver\":\"1.0.0\",\"efuse\":\"0x1C04\",\"product_revision\":\"WTM21 REV1\",\"vendor\":\"Enapter\",\"model\":\"WTM\"}"}}
-
-{"type":"ping","message":"1606485743"}
-
-{"type":"message","identifier":"{\"channel\":\"DeviceChannel\",\"hardware_id\":\"SIM-FRANK\"}","message":{"topic":"telemetry","payload":"{\"timestamp\":1606485747,\"status\":\"ok\",\"PUMP_out_power\":false,\"SV01_out_open\":false,\"LSH_in\":false,\"LSL_in\":false,\"WPS01_in\":false,\"BUTTON_in\":false,\"TT01_in_c\":2.0,\"TT02_in_c\":1.0,\"CS01_in_v\":4.0,\"CS01_in_a\":3.0,\"CT01_in_v\":6.0,\"CT01_in_uscm\":5.0,\"CT01_in_uscm_comp\":9.0,\"LT01_in_v\":8.0,\"LT01_in_l\":7.0,\"last_calibration\":1611756147}"}}
-
-{"type":"ping","message":"1606485746"}
-
-{"type":"message","identifier":"{\"channel\":\"OtherChannel\",\"hardware_id\":\"SIM-WTM\"}","message":{"topic":"telemetry","payload":"{\"timestamp\":1606485747,\"status\":\"ok\",\"PUMP_out_power\":false,\"SV01_out_open\":false,\"LSH_in\":false,\"LSL_in\":false,\"WPS01_in\":false,\"BUTTON_in\":false,\"TT01_in_c\":2.0,\"TT02_in_c\":1.0,\"CS01_in_v\":4.0,\"CS01_in_a\":3.0,\"CT01_in_v\":6.0,\"CT01_in_uscm\":5.0,\"CT01_in_uscm_comp\":9.0,\"LT01_in_v\":8.0,\"LT01_in_l\":7.0,\"last_calibration\":1611756147}"}}
-
-{"type":"ping","message":"1606485749"}
-
-{"type":"message","identifier":"{\"hardware_id\":\"SIM-WTM\",\"channel\":\"DeviceChannel\"}","message":{"topic":"telemetry","payload":"{\"timestamp\":1606486092,\"status\":\"ok\",\"PUMP_out_power\":false,\"SV01_out_open\":false,\"LSH_in\":false,\"LSL_in\":false,\"WPS01_in\":false,\"BUTTON_in\":false,\"TT01_in_c\":1.0,\"TT02_in_c\":2.0,\"CS01_in_v\":3.0,\"CS01_in_a\":4.0,\"CT01_in_v\":5.0,\"CT01_in_uscm\":6.0,\"CT01_in_uscm_comp\":10.0,\"LT01_in_v\":7.0,\"LT01_in_l\":8.0,\"last_calibration\":1611756492}"}}
diff --git a/internal/app/enaptercli/testdata/device_logs/simple/output b/internal/app/enaptercli/testdata/device_logs/simple/output
deleted file mode 100644
index 3277fb5..0000000
--- a/internal/app/enaptercli/testdata/device_logs/simple/output
+++ /dev/null
@@ -1,6 +0,0 @@
-[connection] welcome
-[connection] confirm_subscription
-[register] {"timestamp":1606485742,"fw_ver":"1.0.0","efuse":"0x1C04","product_revision":"WTM21 REV1","vendor":"Enapter","model":"WTM"}
-[read_error] skip message with unknown identifier map[channel:DeviceChannel hardware_id:SIM-FRANK]
-[read_error] skip message with unknown identifier map[channel:OtherChannel hardware_id:SIM-WTM]
-[telemetry] {"timestamp":1606486092,"status":"ok","PUMP_out_power":false,"SV01_out_open":false,"LSH_in":false,"LSL_in":false,"WPS01_in":false,"BUTTON_in":false,"TT01_in_c":1.0,"TT02_in_c":2.0,"CS01_in_v":3.0,"CS01_in_a":4.0,"CT01_in_v":5.0,"CT01_in_uscm":6.0,"CT01_in_uscm_comp":10.0,"LT01_in_v":7.0,"LT01_in_l":8.0,"last_calibration":1611756492}
diff --git a/internal/app/enaptercli/testdata/device_upload/cli_message/output b/internal/app/enaptercli/testdata/device_upload/cli_message/output
deleted file mode 100644
index 1b92681..0000000
--- a/internal/app/enaptercli/testdata/device_upload/cli_message/output
+++ /dev/null
@@ -1,9 +0,0 @@
-Blueprint files to be uploaded:
-* testdata/device_upload/simple/blueprint/manifest.yml
-VERSION IS OUTDATED
-upload started with operation id 25
-[#25] 2020-12-09T14:02:07Z [INFO] Started uploading blueprint[id=d428e77c-3081-4873-b343-2f8f96d9cadc] on device[hardware_id=SIM-WTM]
-[#25] 2020-12-09T14:02:07Z [INFO] Generating configuration for uploading
-[#25] 2020-12-09T14:02:07Z [INFO] Updating configuration on the platform
-[#25] 2020-12-09T14:02:07Z [INFO] Uploading blueprint finished successfully
-Done!
diff --git a/internal/app/enaptercli/testdata/device_upload/cli_message/requests b/internal/app/enaptercli/testdata/device_upload/cli_message/requests
deleted file mode 100644
index 8efcf61..0000000
--- a/internal/app/enaptercli/testdata/device_upload/cli_message/requests
+++ /dev/null
@@ -1,7 +0,0 @@
-{"query":"mutation($input:UploadBlueprintInput!){device{uploadBlueprint(input: $input){data{code,message,title,operationId},errors{code,message,path,title}}}}","variables":{"input":{"blueprint":"data:application/gzip;base64,UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAAMAAAAbWFuaWZlc3QueW1sAQAA//9QSwcIAAAAAAUAAAAAAAAAUEsBAhQAFAAIAAgAAAAAAAAAAAAFAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAG1hbmlmZXN0LnltbFBLBQYAAAAAAQABADoAAAA/AAAAAAA=","hardwareId":"SIM-WTM"}}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"25"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"MQ","hardware_id":"SIM-WTM","operation_id":"25"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"25"}}
diff --git a/internal/app/enaptercli/testdata/device_upload/cli_message/responses b/internal/app/enaptercli/testdata/device_upload/cli_message/responses
deleted file mode 100644
index cefce05..0000000
--- a/internal/app/enaptercli/testdata/device_upload/cli_message/responses
+++ /dev/null
@@ -1,4 +0,0 @@
-{"data":{"device":{"uploadBlueprint":{"data":{"code":"started","message":"Uploading blueprint successfully started.","title":"Started","operationId":"25"},"errors":null}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"IN_PROGRESS","logs":{"edges":[{"cursor":"MQ","node":{"payload":"Started uploading blueprint[id=d428e77c-3081-4873-b343-2f8f96d9cadc] on device[hardware_id=SIM-WTM]","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"Mg","node":{"payload":"Generating configuration for uploading","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}},{"cursor":"Mw","node":{"payload":"Updating configuration on the platform","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}},{"cursor":"NA","node":{"payload":"Uploading blueprint finished successfully","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload/cli_message/settings.json b/internal/app/enaptercli/testdata/device_upload/cli_message/settings.json
deleted file mode 100644
index 18d48a5..0000000
--- a/internal/app/enaptercli/testdata/device_upload/cli_message/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "hardware_id": "SIM-WTM",
- "cli_message": "VERSION IS OUTDATED"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload/simple/blueprint/manifest.yml b/internal/app/enaptercli/testdata/device_upload/simple/blueprint/manifest.yml
deleted file mode 100644
index e69de29..0000000
diff --git a/internal/app/enaptercli/testdata/device_upload/simple/output b/internal/app/enaptercli/testdata/device_upload/simple/output
deleted file mode 100644
index 40f2098..0000000
--- a/internal/app/enaptercli/testdata/device_upload/simple/output
+++ /dev/null
@@ -1,8 +0,0 @@
-Blueprint files to be uploaded:
-* testdata/device_upload/simple/blueprint/manifest.yml
-upload started with operation id 25
-[#25] 2020-12-09T14:02:07Z [INFO] Started uploading blueprint[id=d428e77c-3081-4873-b343-2f8f96d9cadc] on device[hardware_id=SIM-WTM]
-[#25] 2020-12-09T14:02:07Z [INFO] Generating configuration for uploading
-[#25] 2020-12-09T14:02:07Z [INFO] Updating configuration on the platform
-[#25] 2020-12-09T14:02:07Z [INFO] Uploading blueprint finished successfully
-Done!
diff --git a/internal/app/enaptercli/testdata/device_upload/simple/requests b/internal/app/enaptercli/testdata/device_upload/simple/requests
deleted file mode 100644
index 8efcf61..0000000
--- a/internal/app/enaptercli/testdata/device_upload/simple/requests
+++ /dev/null
@@ -1,7 +0,0 @@
-{"query":"mutation($input:UploadBlueprintInput!){device{uploadBlueprint(input: $input){data{code,message,title,operationId},errors{code,message,path,title}}}}","variables":{"input":{"blueprint":"data:application/gzip;base64,UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAAMAAAAbWFuaWZlc3QueW1sAQAA//9QSwcIAAAAAAUAAAAAAAAAUEsBAhQAFAAIAAgAAAAAAAAAAAAFAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAG1hbmlmZXN0LnltbFBLBQYAAAAAAQABADoAAAA/AAAAAAA=","hardwareId":"SIM-WTM"}}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"25"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"MQ","hardware_id":"SIM-WTM","operation_id":"25"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"25"}}
diff --git a/internal/app/enaptercli/testdata/device_upload/simple/responses b/internal/app/enaptercli/testdata/device_upload/simple/responses
deleted file mode 100644
index cefce05..0000000
--- a/internal/app/enaptercli/testdata/device_upload/simple/responses
+++ /dev/null
@@ -1,4 +0,0 @@
-{"data":{"device":{"uploadBlueprint":{"data":{"code":"started","message":"Uploading blueprint successfully started.","title":"Started","operationId":"25"},"errors":null}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"IN_PROGRESS","logs":{"edges":[{"cursor":"MQ","node":{"payload":"Started uploading blueprint[id=d428e77c-3081-4873-b343-2f8f96d9cadc] on device[hardware_id=SIM-WTM]","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"Mg","node":{"payload":"Generating configuration for uploading","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}},{"cursor":"Mw","node":{"payload":"Updating configuration on the platform","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}},{"cursor":"NA","node":{"payload":"Uploading blueprint finished successfully","createdAt":"2020-12-09T14:02:07Z","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload/simple/settings.json b/internal/app/enaptercli/testdata/device_upload/simple/settings.json
deleted file mode 100644
index e2bb1f5..0000000
--- a/internal/app/enaptercli/testdata/device_upload/simple/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "hardware_id": "SIM-WTM"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload/upload_errors/output b/internal/app/enaptercli/testdata/device_upload/upload_errors/output
deleted file mode 100644
index 8ba1c83..0000000
--- a/internal/app/enaptercli/testdata/device_upload/upload_errors/output
+++ /dev/null
@@ -1,5 +0,0 @@
-Blueprint files to be uploaded:
-* testdata/device_upload/simple/blueprint/manifest.yml
-[ERROR] hmm... wait a minute
-[ERROR] oops!
-app exit with error: request execution failed
diff --git a/internal/app/enaptercli/testdata/device_upload/upload_errors/requests b/internal/app/enaptercli/testdata/device_upload/upload_errors/requests
deleted file mode 100644
index e74f0f1..0000000
--- a/internal/app/enaptercli/testdata/device_upload/upload_errors/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"mutation($input:UploadBlueprintInput!){device{uploadBlueprint(input: $input){data{code,message,title,operationId},errors{code,message,path,title}}}}","variables":{"input":{"blueprint":"data:application/gzip;base64,UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAAMAAAAbWFuaWZlc3QueW1sAQAA//9QSwcIAAAAAAUAAAAAAAAAUEsBAhQAFAAIAAgAAAAAAAAAAAAFAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAG1hbmlmZXN0LnltbFBLBQYAAAAAAQABADoAAAA/AAAAAAA=","hardwareId":"SIM-WTM"}}}
diff --git a/internal/app/enaptercli/testdata/device_upload/upload_errors/responses b/internal/app/enaptercli/testdata/device_upload/upload_errors/responses
deleted file mode 100644
index 31dfdfd..0000000
--- a/internal/app/enaptercli/testdata/device_upload/upload_errors/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"device":{"uploadBlueprint":{"data":null,"errors":[{"code":"warning","message":"hmm... wait a minute","title":"Started"},{"code":"fatal","message":"oops!","title":"Started"}]}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload/upload_errors/settings.json b/internal/app/enaptercli/testdata/device_upload/upload_errors/settings.json
deleted file mode 100644
index e2bb1f5..0000000
--- a/internal/app/enaptercli/testdata/device_upload/upload_errors/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "hardware_id": "SIM-WTM"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/output b/internal/app/enaptercli/testdata/device_upload_logs/cli_message/output
deleted file mode 100644
index a4cd9d1..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/output
+++ /dev/null
@@ -1,5 +0,0 @@
-VERSION IS OUTDATED
-[#5] 2020-12-17T13:32:57Z [INFO] Started uploading blueprint[id=42cc8af1-cc60-4eeb-972f-0c0bfa6e3df5] on device[hardware_id=SIM-WTM]
-[#5] 2020-12-17T13:32:57Z [INFO] Generating configuration for uploading
-[#5] 2020-12-17T13:32:57Z [INFO] Updating configuration on the platform
-[#5] 2020-12-17T13:32:57Z [INFO] Uploading blueprint finished successfully
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/requests b/internal/app/enaptercli/testdata/device_upload_logs/cli_message/requests
deleted file mode 100644
index d2f87b8..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/requests
+++ /dev/null
@@ -1,3 +0,0 @@
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"5"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"5"}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/responses b/internal/app/enaptercli/testdata/device_upload_logs/cli_message/responses
deleted file mode 100644
index 5588761..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/responses
+++ /dev/null
@@ -1,2 +0,0 @@
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"MQ","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Started uploading blueprint[id=42cc8af1-cc60-4eeb-972f-0c0bfa6e3df5] on device[hardware_id=SIM-WTM]","severity":"INFO"}},{"cursor":"Mg","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Generating configuration for uploading","severity":"INFO"}},{"cursor":"Mw","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Updating configuration on the platform","severity":"INFO"}},{"cursor":"NA","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Uploading blueprint finished successfully","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/cli_message/settings.json
deleted file mode 100644
index 20c3a13..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/cli_message/settings.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "hardware_id": "SIM-WTM",
- "operation_id": "5",
- "cli_message": "VERSION IS OUTDATED"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/simple/output b/internal/app/enaptercli/testdata/device_upload_logs/simple/output
deleted file mode 100644
index 39a229e..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/simple/output
+++ /dev/null
@@ -1,4 +0,0 @@
-[#5] 2020-12-17T13:32:57Z [INFO] Started uploading blueprint[id=42cc8af1-cc60-4eeb-972f-0c0bfa6e3df5] on device[hardware_id=SIM-WTM]
-[#5] 2020-12-17T13:32:57Z [INFO] Generating configuration for uploading
-[#5] 2020-12-17T13:32:57Z [INFO] Updating configuration on the platform
-[#5] 2020-12-17T13:32:57Z [INFO] Uploading blueprint finished successfully
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/simple/requests b/internal/app/enaptercli/testdata/device_upload_logs/simple/requests
deleted file mode 100644
index d2f87b8..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/simple/requests
+++ /dev/null
@@ -1,3 +0,0 @@
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"5"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"5"}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/simple/responses b/internal/app/enaptercli/testdata/device_upload_logs/simple/responses
deleted file mode 100644
index 5588761..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/simple/responses
+++ /dev/null
@@ -1,2 +0,0 @@
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"MQ","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Started uploading blueprint[id=42cc8af1-cc60-4eeb-972f-0c0bfa6e3df5] on device[hardware_id=SIM-WTM]","severity":"INFO"}},{"cursor":"Mg","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Generating configuration for uploading","severity":"INFO"}},{"cursor":"Mw","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Updating configuration on the platform","severity":"INFO"}},{"cursor":"NA","node":{"createdAt":"2020-12-17T13:32:57Z","payload":"Uploading blueprint finished successfully","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/simple/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/simple/settings.json
deleted file mode 100644
index af8682b..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/simple/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "hardware_id": "SIM-WTM",
- "operation_id": "5"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/output b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/output
deleted file mode 100644
index 7367de8..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/output
+++ /dev/null
@@ -1 +0,0 @@
-app exit with error: request execution failed: device not found
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/requests b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/requests
deleted file mode 100644
index 450f634..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"54"}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/responses b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/responses
deleted file mode 100644
index e1c8cf9..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"device": null}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/settings.json
deleted file mode 100644
index f5b1cbf..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "hardware_id": "SIM-WTM",
- "operation_id": "54"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/output b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/output
deleted file mode 100644
index 7367de8..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/output
+++ /dev/null
@@ -1 +0,0 @@
-app exit with error: request execution failed: device not found
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/requests b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/requests
deleted file mode 100644
index b8be040..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"query($hardware_id:ID!$last_int:Int!){device(hardwareId: $hardware_id){blueprintUpdateOperations(last: $last_int){nodes{id}}}}","variables":{"hardware_id":"SIM-WTM","last_int":2}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/responses b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/responses
deleted file mode 100644
index e1c8cf9..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"device": null}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/settings.json
deleted file mode 100644
index e2bb1f5..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_hardware_id_without_operation_id/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "hardware_id": "SIM-WTM"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/output b/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/output
deleted file mode 100644
index 8f6a001..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/output
+++ /dev/null
@@ -1 +0,0 @@
-app exit with error: request execution failed: operation not found
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/requests b/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/requests
deleted file mode 100644
index 450f634..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"54"}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/responses b/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/responses
deleted file mode 100644
index 692d3f2..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"device": {"blueprintUpdateOperation":null}}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/settings.json
deleted file mode 100644
index f5b1cbf..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/unknown_operation_id/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "hardware_id": "SIM-WTM",
- "operation_id": "54"
-}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/output b/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/output
deleted file mode 100644
index 2502ef5..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/output
+++ /dev/null
@@ -1,8 +0,0 @@
-[#17] 2020-12-17T16:07:37Z [INFO] Started uploading blueprint[id=84606e67-d377-4f11-b3e4-421f08265ec7] on device[hardware_id=SIM-WTM]
-[#17] 2020-12-17T16:07:37Z [INFO] Generating configuration for uploading
-[#17] 2020-12-17T16:07:37Z [INFO] Updating configuration on the platform
-[#17] 2020-12-17T16:07:37Z [INFO] Uploading blueprint finished successfully
-[#20] 2020-12-21T11:53:14Z [INFO] Started uploading blueprint[id=a7be3c13-e138-4c43-a7c0-db50ef775613] on device[hardware_id=SIM-WTM]
-[#20] 2020-12-21T11:53:14Z [INFO] Generating configuration for uploading
-[#20] 2020-12-21T11:53:14Z [INFO] Updating configuration on the platform
-[#20] 2020-12-21T11:53:14Z [INFO] Uploading blueprint finished successfully
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/requests b/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/requests
deleted file mode 100644
index 52f4479..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/requests
+++ /dev/null
@@ -1,9 +0,0 @@
-{"query":"query($hardware_id:ID!$last_int:Int!){device(hardwareId: $hardware_id){blueprintUpdateOperations(last: $last_int){nodes{id}}}}","variables":{"hardware_id":"SIM-WTM","last_int":2}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"17"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"17"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"","hardware_id":"SIM-WTM","operation_id":"20"}}
-
-{"query":"query($after_cursor:String!$hardware_id:ID!$operation_id:ID!){device(hardwareId: $hardware_id){blueprintUpdateOperation(id: $operation_id){status,logs(after: $after_cursor){edges{cursor,node{payload,createdAt,severity}}}}}}","variables":{"after_cursor":"NA","hardware_id":"SIM-WTM","operation_id":"20"}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/responses b/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/responses
deleted file mode 100644
index 8127b73..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/responses
+++ /dev/null
@@ -1,5 +0,0 @@
-{"data":{"device":{"blueprintUpdateOperations":{"nodes":[{"id":"17"},{"id":"20"}]}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"MQ","node":{"createdAt":"2020-12-17T16:07:37Z","payload":"Started uploading blueprint[id=84606e67-d377-4f11-b3e4-421f08265ec7] on device[hardware_id=SIM-WTM]","severity":"INFO"}},{"cursor":"Mg","node":{"createdAt":"2020-12-17T16:07:37Z","payload":"Generating configuration for uploading","severity":"INFO"}},{"cursor":"Mw","node":{"createdAt":"2020-12-17T16:07:37Z","payload":"Updating configuration on the platform","severity":"INFO"}},{"cursor":"NA","node":{"createdAt":"2020-12-17T16:07:37Z","payload":"Uploading blueprint finished successfully","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[{"cursor":"MQ","node":{"createdAt":"2020-12-21T11:53:14Z","payload":"Started uploading blueprint[id=a7be3c13-e138-4c43-a7c0-db50ef775613] on device[hardware_id=SIM-WTM]","severity":"INFO"}},{"cursor":"Mg","node":{"createdAt":"2020-12-21T11:53:14Z","payload":"Generating configuration for uploading","severity":"INFO"}},{"cursor":"Mw","node":{"createdAt":"2020-12-21T11:53:14Z","payload":"Updating configuration on the platform","severity":"INFO"}},{"cursor":"NA","node":{"createdAt":"2020-12-21T11:53:14Z","payload":"Uploading blueprint finished successfully","severity":"INFO"}}]}}}}}
-{"data":{"device":{"blueprintUpdateOperation":{"status":"SUCCEEDED","logs":{"edges":[]}}}}}
diff --git a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/settings.json b/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/settings.json
deleted file mode 100644
index e2bb1f5..0000000
--- a/internal/app/enaptercli/testdata/device_upload_logs/without_operation_id/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "hardware_id": "SIM-WTM"
-}
diff --git a/internal/app/enaptercli/testdata/helps/enapter b/internal/app/enaptercli/testdata/helps/enapter
index b916872..159cf84 100644
--- a/internal/app/enaptercli/testdata/helps/enapter
+++ b/internal/app/enaptercli/testdata/helps/enapter
@@ -1,18 +1,25 @@
NAME:
- enaptercli.test - Command line interface for Enapter services.
+ enaptercli.test - Command Line Interface (CLI) for Enapter services.
USAGE:
- enaptercli.test [global options] command [command options] [arguments...]
+ enaptercli.test [global options] command [command options]
DESCRIPTION:
- Enapter CLI requires access token for authentication. The token can be obtained in your Enapter Cloud account settings.
-
- Configure API token using ENAPTER_API_TOKEN environment variable or using --token global option.
+ The Enapter CLI requires an access token for authentication. You can obtain the token in your Enapter Cloud account settings.
COMMANDS:
- devices Device information and management commands.
- rules Rules information and management commands.
- help, h Shows a list of commands or help for one command
+ site Manage sites
+ device Manage devices
+ blueprint Manage blueprints
+ rule-engine Manage the rule engine
+ connection Manage connections to Enapter Cloud and Gateways
+ help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
- --help, -h show help (default: false)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint b/internal/app/enaptercli/testdata/helps/enapter blueprint
new file mode 100644
index 0000000..4c25e20
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test blueprint - Manage blueprints
+
+USAGE:
+ enaptercli.test blueprint command [command options]
+
+COMMANDS:
+ profiles Manage blueprint profiles
+ upload Upload the blueprint to the Platform
+ download Download the blueprint zip from the Platform
+ get Retrieve blueprint metadata
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint download b/internal/app/enaptercli/testdata/helps/enapter blueprint download
new file mode 100644
index 0000000..7081ba2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint download
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test blueprint download - Download the blueprint zip from the Platform
+
+USAGE:
+ enaptercli.test blueprint download [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --blueprint-id value, -b value blueprint name or ID to download
+ --output value, -o value blueprint file name to save the blueprint
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint get b/internal/app/enaptercli/testdata/helps/enapter blueprint get
new file mode 100644
index 0000000..b104785
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint get
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test blueprint get - Retrieve blueprint metadata
+
+USAGE:
+ enaptercli.test blueprint get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --blueprint-id value, -b value blueprint name or ID to retrieve
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint profiles b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles
new file mode 100644
index 0000000..e8d0dde
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test blueprint profiles - Manage blueprint profiles
+
+USAGE:
+ enaptercli.test blueprint profiles command [command options]
+
+COMMANDS:
+ download Download profiles zip from the Platform
+ upload Upload profiles to the Platform
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint profiles download b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles download
new file mode 100644
index 0000000..88a0bfe
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles download
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test blueprint profiles download - Download profiles zip from the Platform
+
+USAGE:
+ enaptercli.test blueprint profiles download [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --output value, -o value file name to save the downloaded profiles
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint profiles upload b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles upload
new file mode 100644
index 0000000..1d5da02
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles upload
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test blueprint profiles upload - Upload profiles to the Platform
+
+USAGE:
+ enaptercli.test blueprint profiles upload [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --path value, -p value profiles zip file path
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint upload b/internal/app/enaptercli/testdata/helps/enapter blueprint upload
new file mode 100644
index 0000000..7932fb2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint upload
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test blueprint upload - Upload the blueprint to the Platform
+
+USAGE:
+ enaptercli.test blueprint upload [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --path value, -p value blueprint path (zip file or directory)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection b/internal/app/enaptercli/testdata/helps/enapter connection
new file mode 100644
index 0000000..8438469
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection
@@ -0,0 +1,15 @@
+NAME:
+ enaptercli.test connection - Manage connections to Enapter Cloud and Gateways
+
+USAGE:
+ enaptercli.test connection command [command options]
+
+COMMANDS:
+ add Add a new connection
+ remove Remove a connection
+ list List all connections
+ set-default Set default connection
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection add b/internal/app/enaptercli/testdata/helps/enapter connection add
new file mode 100644
index 0000000..9ebed3e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection add
@@ -0,0 +1,14 @@
+NAME:
+ enaptercli.test connection add - Add a new connection
+
+USAGE:
+ enaptercli.test connection add [command options]
+
+OPTIONS:
+ --name value connection name
+ --gateway indicates that the connection is to a Gateway (default: false)
+ --url value Enapter API base URL (default: "https://api.enapter.com")
+ --token value Enapter API access token
+ --site-id value if specified, the connection will be limited to this site (available only for Cloud connections)
+ --allow-insecure allow insecure connections to the Enapter API (default: false)
+ --help, -h show help
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection list b/internal/app/enaptercli/testdata/helps/enapter connection list
new file mode 100644
index 0000000..84afb87
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection list
@@ -0,0 +1,8 @@
+NAME:
+ enaptercli.test connection list - List all connections
+
+USAGE:
+ enaptercli.test connection list [command options]
+
+OPTIONS:
+ --help, -h show help
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection remove b/internal/app/enaptercli/testdata/helps/enapter connection remove
new file mode 100644
index 0000000..207770c
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection remove
@@ -0,0 +1,9 @@
+NAME:
+ enaptercli.test connection remove - Remove a connection
+
+USAGE:
+ enaptercli.test connection remove [command options]
+
+OPTIONS:
+ --name value connection name
+ --help, -h show help
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection set-default b/internal/app/enaptercli/testdata/helps/enapter connection set-default
new file mode 100644
index 0000000..eb243ae
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection set-default
@@ -0,0 +1,9 @@
+NAME:
+ enaptercli.test connection set-default - Set default connection
+
+USAGE:
+ enaptercli.test connection set-default [command options]
+
+OPTIONS:
+ --name value connection name
+ --help, -h show help
diff --git a/internal/app/enaptercli/testdata/helps/enapter device b/internal/app/enaptercli/testdata/helps/enapter device
new file mode 100644
index 0000000..131a4c7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device
@@ -0,0 +1,28 @@
+NAME:
+ enaptercli.test device - Manage devices
+
+USAGE:
+ enaptercli.test device command [command options]
+
+COMMANDS:
+ create Create devices of different types
+ list List user devices ordered by device ID
+ get Retrieve device information
+ change-blueprint Change device blueprint
+ logs Show device logs
+ update Update a device
+ delete Delete a device
+ command Manage device commands
+ telemetry Show device telemetry
+ communication-config Manage device communication config
+ run-terminal Run new remote terminal session
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device change-blueprint b/internal/app/enaptercli/testdata/helps/enapter device change-blueprint
new file mode 100644
index 0000000..c5d106a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device change-blueprint
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test device change-blueprint - Change device blueprint
+
+USAGE:
+ enaptercli.test device change-blueprint [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --blueprint-id value, -b value blueprint ID to use as new device blueprint
+ --blueprint-path value blueprint path (zip file or directory) to use as new device blueprint
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device command b/internal/app/enaptercli/testdata/helps/enapter device command
new file mode 100644
index 0000000..a29328a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device command - Manage device commands
+
+USAGE:
+ enaptercli.test device command command [command options]
+
+COMMANDS:
+ execute Execute a device command
+ list List device command executions
+ get Retrieve a device command execution
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device command execute b/internal/app/enaptercli/testdata/helps/enapter device command execute
new file mode 100644
index 0000000..b041eda
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command execute
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test device command execute - Execute a device command
+
+USAGE:
+ enaptercli.test device command execute [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --name value command name
+ --arguments value command arguments (should be a JSON string)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device command get b/internal/app/enaptercli/testdata/helps/enapter device command get
new file mode 100644
index 0000000..45b0868
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command get
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test device command get - Retrieve a device command execution
+
+USAGE:
+ enaptercli.test device command get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --execution-id value execution ID
+ --expand value [ --expand value ] coma-separated list of expanded options (supported values: log)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device command list b/internal/app/enaptercli/testdata/helps/enapter device command list
new file mode 100644
index 0000000..a189c0b
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command list
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test device command list - List device command executions
+
+USAGE:
+ enaptercli.test device command list [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device communication-config b/internal/app/enaptercli/testdata/helps/enapter device communication-config
new file mode 100644
index 0000000..9ca4f7f
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device communication-config
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test device communication-config - Manage device communication config
+
+USAGE:
+ enaptercli.test device communication-config command [command options]
+
+COMMANDS:
+ generate Generate a new communication config for device
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device communication-config generate b/internal/app/enaptercli/testdata/helps/enapter device communication-config generate
new file mode 100644
index 0000000..ffc8338
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device communication-config generate
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device communication-config generate - Generate a new communication config for device
+
+USAGE:
+ enaptercli.test device communication-config generate [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --protocol value connection protocol (supported values: MQTT, MQTTS)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device create b/internal/app/enaptercli/testdata/helps/enapter device create
new file mode 100644
index 0000000..fe5efa7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test device create - Create devices of different types
+
+USAGE:
+ enaptercli.test device create command [command options]
+
+COMMANDS:
+ standalone Create a new standalone device
+ lua-device Create a new Lua device
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device create lua-device b/internal/app/enaptercli/testdata/helps/enapter device create lua-device
new file mode 100644
index 0000000..fe4ea93
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create lua-device
@@ -0,0 +1,23 @@
+NAME:
+ enaptercli.test device create lua-device - Create a new Lua device
+
+USAGE:
+ enaptercli.test device create lua-device [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --runtime-id value, -r value UCM device ID where the new Lua device will run
+ --device-name value, -n value name for the new Lua device
+ --device-slug value slug for the new Lua device
+ --blueprint-id value, -b value blueprint ID to use for the new Lua device
+ --blueprint-path value blueprint path (zip file or directory) to use for the new Lua device
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device create standalone b/internal/app/enaptercli/testdata/helps/enapter device create standalone
new file mode 100644
index 0000000..f98d0f1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create standalone
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device create standalone - Create a new standalone device
+
+USAGE:
+ enaptercli.test device create standalone [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value, -s value site ID where the device will be created
+ --device-name value, -n value name for the new device
+ --device-slug value slug for the new standalone device
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device delete b/internal/app/enaptercli/testdata/helps/enapter device delete
new file mode 100644
index 0000000..44e27cd
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device delete
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test device delete - Delete a device
+
+USAGE:
+ enaptercli.test device delete [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device get b/internal/app/enaptercli/testdata/helps/enapter device get
new file mode 100644
index 0000000..84246aa
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device get
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device get - Retrieve device information
+
+USAGE:
+ enaptercli.test device get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --expand value [ --expand value ] coma-separated list of expanded device information (supported values: connectivity, manifest, properties, communication, site)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device list b/internal/app/enaptercli/testdata/helps/enapter device list
new file mode 100644
index 0000000..87711a7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device list
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device list - List user devices ordered by device ID
+
+USAGE:
+ enaptercli.test device list [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --expand value [ --expand value ] coma-separated list of expanded device information (supported values: connectivity, manifest, properties, communication, site)
+ --limit value maximum number of devices to retrieve (default: retrieves all)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device logs b/internal/app/enaptercli/testdata/helps/enapter device logs
new file mode 100644
index 0000000..cef3a68
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device logs
@@ -0,0 +1,27 @@
+NAME:
+ enaptercli.test device logs - Show device logs
+
+USAGE:
+ enaptercli.test device logs [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --follow, -f follow the log output (default: false)
+ --from value from timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)
+ --to value to timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)
+ --limit value, -l value maximum number of logs to retrieve (default: 0)
+ --offset value, -o value number of logs to skip when retrieving (default: 0)
+ --severity value, -s value filter logs by severity
+ --order value order logs by criteria (RECEIVED_AT_ASC[default], RECEIVED_AT_DESC)
+ --show value filter logs by criteria (ALL[default], PERSISTED_ONLY, TEMPORARY_ONLY)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device run-terminal b/internal/app/enaptercli/testdata/helps/enapter device run-terminal
new file mode 100644
index 0000000..53ba0f3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device run-terminal
@@ -0,0 +1,22 @@
+NAME:
+ enaptercli.test device run-terminal - Run new remote terminal session
+
+USAGE:
+ enaptercli.test device run-terminal [command options]
+
+DESCRIPTION:
+ Remote terminal feature should be enabled in gateway settings. Use Ctrl+] sequence to force connection close.
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value gateway device ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device telemetry b/internal/app/enaptercli/testdata/helps/enapter device telemetry
new file mode 100644
index 0000000..9b759a3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device telemetry
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device telemetry - Show device telemetry
+
+USAGE:
+ enaptercli.test device telemetry [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --follow, -f follow the telemetry output (default: false)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter device update b/internal/app/enaptercli/testdata/helps/enapter device update
new file mode 100644
index 0000000..1e42322
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device update
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test device update - Update a device
+
+USAGE:
+ enaptercli.test device update [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --device-id value, -d value device ID
+ --name value device name
+ --slug value device slug
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter devices b/internal/app/enaptercli/testdata/helps/enapter devices
deleted file mode 100644
index dddf861..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter devices
+++ /dev/null
@@ -1,16 +0,0 @@
-NAME:
- enaptercli.test devices - Device information and management commands.
-
-USAGE:
- enaptercli.test devices command [command options] [arguments...]
-
-COMMANDS:
- upload Upload blueprint to a device
- logs Stream logs from a device
- upload-logs Show blueprint uploading logs
- execute Execute command on device
- help, h Shows a list of commands or help for one command
-
-OPTIONS:
- --help, -h show help (default: false)
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter devices execute b/internal/app/enaptercli/testdata/helps/enapter devices execute
deleted file mode 100644
index 7df8a65..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter devices execute
+++ /dev/null
@@ -1,16 +0,0 @@
-NAME:
- enaptercli.test devices execute - Execute command on device
-
-USAGE:
- enaptercli.test devices execute [command options] [arguments...]
-
-OPTIONS:
- --hardware-id value Hardware ID of the device; can be obtained in cloud.enapter.com
- --command value Command name
- --arguments value Command arguments as JSON object
- --show-progress Enable in-progress responses streaming (default: false)
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter devices logs b/internal/app/enaptercli/testdata/helps/enapter devices logs
deleted file mode 100644
index b68811b..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter devices logs
+++ /dev/null
@@ -1,13 +0,0 @@
-NAME:
- enaptercli.test devices logs - Stream logs from a device
-
-USAGE:
- enaptercli.test devices logs [command options] [arguments...]
-
-OPTIONS:
- --hardware-id value Hardware ID of the device; can be obtained in cloud.enapter.com
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter devices upload b/internal/app/enaptercli/testdata/helps/enapter devices upload
deleted file mode 100644
index 89cfd31..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter devices upload
+++ /dev/null
@@ -1,18 +0,0 @@
-NAME:
- enaptercli.test devices upload - Upload blueprint to a device
-
-USAGE:
- enaptercli.test devices upload [command options] [arguments...]
-
-DESCRIPTION:
- Blueprint combines device capabilities declaration and Lua firmware for Enapter UCM. The command updates device blueprint and uploads the firmware to the UCM. Learn more about Enapter Blueprints at https://handbook.enapter.com/blueprints.
-
-OPTIONS:
- --hardware-id value Hardware ID of the device; can be obtained in cloud.enapter.com
- --timeout value Time to wait for blueprint uploading (default: 30s)
- --blueprint-dir value Directory which contains blueprint file
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter devices upload-logs b/internal/app/enaptercli/testdata/helps/enapter devices upload-logs
deleted file mode 100644
index fdd775d..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter devices upload-logs
+++ /dev/null
@@ -1,15 +0,0 @@
-NAME:
- enaptercli.test devices upload-logs - Show blueprint uploading logs
-
-USAGE:
- enaptercli.test devices upload-logs [command options] [arguments...]
-
-OPTIONS:
- --hardware-id value Hardware ID of the device; can be obtained in cloud.enapter.com
- --timeout value Time to wait for blueprint uploading (default: 30s)
- --operation-id value Uploading operation ID (optional)
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine b/internal/app/enaptercli/testdata/helps/enapter rule-engine
new file mode 100644
index 0000000..597bb25
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine
@@ -0,0 +1,21 @@
+NAME:
+ enaptercli.test rule-engine - Manage the rule engine
+
+USAGE:
+ enaptercli.test rule-engine command [command options]
+
+COMMANDS:
+ get Retrieve the rule engine
+ suspend Suspend execution of rules
+ resume Resume execution of rules
+ rule Manage rules
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine get b/internal/app/enaptercli/testdata/helps/enapter rule-engine get
new file mode 100644
index 0000000..e87cfe9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine get
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test rule-engine get - Retrieve the rule engine
+
+USAGE:
+ enaptercli.test rule-engine get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine resume b/internal/app/enaptercli/testdata/helps/enapter rule-engine resume
new file mode 100644
index 0000000..a25a105
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine resume
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test rule-engine resume - Resume execution of rules
+
+USAGE:
+ enaptercli.test rule-engine resume [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule
new file mode 100644
index 0000000..6996d20
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule
@@ -0,0 +1,26 @@
+NAME:
+ enaptercli.test rule-engine rule - Manage rules
+
+USAGE:
+ enaptercli.test rule-engine rule command [command options]
+
+COMMANDS:
+ create Create a new rule
+ delete Delete a rule
+ disable Disable one or more rules
+ enable Enable one or more rules
+ get Retrieve a rule
+ list List rules
+ update Update a rule
+ update-script Update the script of a rule
+ logs Show rule logs
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule create b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule create
new file mode 100644
index 0000000..24645d9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule create
@@ -0,0 +1,23 @@
+NAME:
+ enaptercli.test rule-engine rule create - Create a new rule
+
+USAGE:
+ enaptercli.test rule-engine rule create [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --slug value Slug for the new rule
+ --script value Path to the file containing the script code
+ --runtime-version value Version of the runtime to use for the script execution (default: "V3")
+ --exec-interval value How frequently to execute the script (compatible only with runtime version 1) in duration format (e.g., 5s, 2m)
+ --disable Disable the rule upon creation (default: false)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule delete b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule delete
new file mode 100644
index 0000000..66a260b
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule delete
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test rule-engine rule delete - Delete a rule
+
+USAGE:
+ enaptercli.test rule-engine rule delete [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value Rule ID or slug
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule disable b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule disable
new file mode 100644
index 0000000..a3e6d94
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule disable
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test rule-engine rule disable - Disable one or more rules
+
+USAGE:
+ enaptercli.test rule-engine rule disable [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value [ --rule-id value ] Rule IDs or slugs
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule enable b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule enable
new file mode 100644
index 0000000..b58bad4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule enable
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test rule-engine rule enable - Enable one or more rules
+
+USAGE:
+ enaptercli.test rule-engine rule enable [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value [ --rule-id value ] Rule IDs or slugs
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule get b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule get
new file mode 100644
index 0000000..e794ae6
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule get
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test rule-engine rule get - Retrieve a rule
+
+USAGE:
+ enaptercli.test rule-engine rule get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value Rule ID or slug
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule list b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule list
new file mode 100644
index 0000000..b400c2a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule list
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test rule-engine rule list - List rules
+
+USAGE:
+ enaptercli.test rule-engine rule list [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule logs b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule logs
new file mode 100644
index 0000000..2d46abc
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule logs
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test rule-engine rule logs - Show rule logs
+
+USAGE:
+ enaptercli.test rule-engine rule logs [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value rule ID
+ --follow, -f follow the log output (default: false)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update
new file mode 100644
index 0000000..9b3f278
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test rule-engine rule update - Update a rule
+
+USAGE:
+ enaptercli.test rule-engine rule update [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value Rule ID or slug to update
+ --slug value A new rule slug
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update-script b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update-script
new file mode 100644
index 0000000..e64f5a8
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update-script
@@ -0,0 +1,22 @@
+NAME:
+ enaptercli.test rule-engine rule update-script - Update the script of a rule
+
+USAGE:
+ enaptercli.test rule-engine rule update-script [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --rule-id value Rule ID or slug to update
+ --script value Path to a file containing the script code
+ --runtime-version value Version of the runtime to use for the script execution (default: "V3")
+ --exec-interval value How frequently to execute the script (compatible only with runtime version 1) in duration format (e.g., 5s, 2m)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rule-engine suspend b/internal/app/enaptercli/testdata/helps/enapter rule-engine suspend
new file mode 100644
index 0000000..e632a47
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine suspend
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test rule-engine suspend - Suspend execution of rules
+
+USAGE:
+ enaptercli.test rule-engine suspend [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter rules logs b/internal/app/enaptercli/testdata/helps/enapter rules logs
deleted file mode 100644
index c831068..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter rules logs
+++ /dev/null
@@ -1,13 +0,0 @@
-NAME:
- enaptercli.test rules logs - Stream logs from a rule
-
-USAGE:
- enaptercli.test rules logs [command options] [arguments...]
-
-OPTIONS:
- --rule-id value Rule ID; can be obtained in cloud.enapter.com
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter rules update b/internal/app/enaptercli/testdata/helps/enapter rules update
deleted file mode 100644
index f92a7af..0000000
--- a/internal/app/enaptercli/testdata/helps/enapter rules update
+++ /dev/null
@@ -1,17 +0,0 @@
-NAME:
- enaptercli.test rules update - Update rule.
-
-USAGE:
- enaptercli.test rules update [command options] [arguments...]
-
-OPTIONS:
- --rule-id value Rule ID; can be obtained in cloud.enapter.com
- --rule-path value Path to file with rule Lua code
- --execution-interval value Rule execution interval in milliseconds (default: chosen by the server)
- --stdlib-version value Version of standard library used by the rule (default: chosen by the server)
- --timeout value Time to wait for rule update (default: 30s)
- --help, -h show help (default: false)
-
-ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
-
diff --git a/internal/app/enaptercli/testdata/helps/enapter site b/internal/app/enaptercli/testdata/helps/enapter site
new file mode 100644
index 0000000..e0cc024
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test site - Manage sites
+
+USAGE:
+ enaptercli.test site command [command options]
+
+COMMANDS:
+ list List user sites
+ get Get a site
+ help, h Shows a list of commands or help for one command
+
+OPTIONS:
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter site get b/internal/app/enaptercli/testdata/helps/enapter site get
new file mode 100644
index 0000000..0d75156
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site get
@@ -0,0 +1,18 @@
+NAME:
+ enaptercli.test site get - Get a site
+
+USAGE:
+ enaptercli.test site get [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --site-id value site ID
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter site list b/internal/app/enaptercli/testdata/helps/enapter site list
new file mode 100644
index 0000000..0ce7752
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site list
@@ -0,0 +1,19 @@
+NAME:
+ enaptercli.test site list - List user sites
+
+USAGE:
+ enaptercli.test site list [command options]
+
+OPTIONS:
+ --connection value, -c value name of the connection to use
+ --api-allow-insecure allow insecure connections to the Enapter API (default: false) [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose log extra details about the operation (default: false)
+ --my-sites returns only sites where user is owner or installer (default: false)
+ --limit value maximum number of sites to retrieve (default: retrieves all)
+ --help, -h show help
+
+ENVIRONMENT VARIABLES:
+ ENAPTER3_API_TOKEN Enapter API access token
+ ENAPTER3_API_URL Enapter API base URL (default: https://api.enapter.com)
+ ENAPTER3_API_ALLOW_INSECURE Allow insecure connections to the Enapter API (default: false)
+
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/cmd.tmpl
new file mode 100644
index 0000000..0d61ed5
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 blueprint get --connection my-conn --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/out b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/out
new file mode 100644
index 0000000..fca5dda
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/out
@@ -0,0 +1 @@
+{"created_at": TODAY!}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/req_0
new file mode 100644
index 0000000..65ad234
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/blueprints/cdd82438-dda8-4f69-aad1-0be9adeab964",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/resp_0
new file mode 100644
index 0000000..fca5dda
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_id/resp_0
@@ -0,0 +1 @@
+{"created_at": TODAY!}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/cmd.tmpl
new file mode 100644
index 0000000..f980f03
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 blueprint get --connection my-conn --blueprint-id test_blueprint_name
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/out b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/out
new file mode 100644
index 0000000..fca5dda
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/out
@@ -0,0 +1 @@
+{"created_at": TODAY!}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/req_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/req_0
new file mode 100644
index 0000000..72fc047
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/blueprints/enapter/test_blueprint_name/latest",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/resp_0
new file mode 100644
index 0000000..fca5dda
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_get_by_name/resp_0
@@ -0,0 +1 @@
+{"created_at": TODAY!}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/cmd.tmpl
new file mode 100644
index 0000000..713bef9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device get --connection my-conn --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/req_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/req_0
new file mode 100644
index 0000000..e16fe8e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_connection_name/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/cmd.tmpl
new file mode 100644
index 0000000..f0ac177
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/cmd.tmpl
@@ -0,0 +1,3 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 connection set-default --name my-conn
+enapter3 device get --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/req_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/req_0
new file mode 100644
index 0000000..e16fe8e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_default_connection/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/cmd.tmpl
new file mode 100644
index 0000000..cf34301
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/cmd.tmpl
@@ -0,0 +1 @@
+enapter3 device get --token 123 --api-url {{.URL}} --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/req_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/req_0
new file mode 100644
index 0000000..8945df1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "123"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_by_flags/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/cmd.tmpl
new file mode 100644
index 0000000..eb1adbb
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/cmd.tmpl
@@ -0,0 +1 @@
+enapter3 device get --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/out
new file mode 100644
index 0000000..2acda60
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_missing/out
@@ -0,0 +1,10 @@
+app exit with error: No connection configured.
+
+Please, specify connection using --connection flag.
+
+To list available connections:
+$ enapter3 connection list
+
+To add a new connection:
+$ enapter3 connection add
+
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/cmd.tmpl
new file mode 100644
index 0000000..3b85c13
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device get --connection my-conn --token {{.Token}} --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/out
new file mode 100644
index 0000000..9d7e60d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/out
@@ -0,0 +1,2 @@
+WARNING: credentials set via environment variables or flags are ignored.
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/req_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/req_0
new file mode 100644
index 0000000..e16fe8e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_mixed/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/cmd.tmpl
new file mode 100644
index 0000000..7250861
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}} --site-id my-site
+enapter3 device get --connection my-conn --site-id other-site --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/out
new file mode 100644
index 0000000..ea896f0
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_mismatch/out
@@ -0,0 +1 @@
+app exit with error: passed site-ID must match the site-ID of the current connection
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/cmd.tmpl
new file mode 100644
index 0000000..9e9d408
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}} --site-id my-site
+enapter3 device get --connection my-conn --site-id my-site --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/out b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/req_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/req_0
new file mode 100644
index 0000000..99cb7dd
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/credentials_site_id_redundancy/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/cmd.tmpl
new file mode 100644
index 0000000..dc26dc1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device change-blueprint --connection my-conn --device-id 427ec09e-ec1e-4760-acc1-50106533b875 --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/out
new file mode 100644
index 0000000..d832236
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/out
@@ -0,0 +1 @@
+{"blueprint": "assigned"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/req_0
new file mode 100644
index 0000000..0a7f3c4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/req_0
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/devices/427ec09e-ec1e-4760-acc1-50106533b875/assign_blueprint",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "55"
+ ],
+ "Content-Type": [
+ "application/json"
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "{\"blueprint_id\":\"cdd82438-dda8-4f69-aad1-0be9adeab964\"}"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/resp_0
new file mode 100644
index 0000000..d832236
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id/resp_0
@@ -0,0 +1 @@
+{"blueprint": "assigned"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/cmd.tmpl
new file mode 100644
index 0000000..17c9b3a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device change-blueprint --connection my-conn --device-id 427ec09e-ec1e-4760-acc1-50106533b875 --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964 --blueprint-path ./testdata/blueprints/bp.zip
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/out b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/out
new file mode 100644
index 0000000..dc69131
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_id_and_path/out
@@ -0,0 +1 @@
+app exit with error: only one of --blueprint-id or --blueprint-path can be specified
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/cmd.tmpl
new file mode 100644
index 0000000..1769b48
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device change-blueprint --connection my-conn --device-id 3b0a0626-2dc4-44a3-ac5a-34d58b7b2a26 --blueprint-path ./testdata/blueprints/simple
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/out b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/out
new file mode 100644
index 0000000..f134b3a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/out
@@ -0,0 +1 @@
+{"blueprint": "assigned by path"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_0
new file mode 100644
index 0000000..0a841e2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_0
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/blueprints/upload",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "400"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "PK\u0003\u0004\u0014\u0000\b\u0000\b\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\f\u0000\u0000\u0000firmware.luaJ\ufffdK,(I-\ufffd\ufffd\ufffdO\ufffdP\ufffdH\ufffd\ufffd\ufffdWH+\ufffd\ufffdUH\ufffd,\ufffd-O,J\ufffd\ufffd)MT\ufffd\ufffd\u0002\u0004\u0000\u0000\ufffd\ufffdPK\u0007\b\ufffd\ufffdv*-\u0000\u0000\u0000'\u0000\u0000\u0000PK\u0003\u0004\u0014\u0000\b\u0000\b\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\f\u0000\u0000\u0000manifest.yml\u0014\ufffd˩\ufffd@\f\u0005н\ufffd\ufffd\u0015\ufffd=\ufffdN5d\ufffd\u0002\ufffdb_\ufffd@3\u0016\ufffdIp\ufffd\ufffd\ufffd\ufffdy\ufffdd6\ufffdc\ufffd\ufffdM\ufffd\ufffd\ufffd\u001b\ufffd\u001e˿\ufffd\ufffd3\ufffdZ\ufffd\u0015*^^2\ufffd\ufffd4\ufffd6\ufffd\ufffdB\u0015`\\IEL\u0013\ufffd\ufffd\ufffdg\ufffd7\u0003\ufffd\u0007\u0015\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd\u001e\ufffd\u0000\u0000\u0000\ufffd\ufffdPK\u0007\b*\u0011\u0019Ae\u0000\u0000\u0000l\u0000\u0000\u0000PK\u0001\u0002\u0014\u0000\u0014\u0000\b\u0000\b\u0000\u0000\u0000\u0000\u0000\ufffd\ufffdv*-\u0000\u0000\u0000'\u0000\u0000\u0000\f\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000firmware.luaPK\u0001\u0002\u0014\u0000\u0014\u0000\b\u0000\b\u0000\u0000\u0000\u0000\u0000*\u0011\u0019Ae\u0000\u0000\u0000l\u0000\u0000\u0000\f\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000g\u0000\u0000\u0000manifest.ymlPK\u0005\u0006\u0000\u0000\u0000\u0000\u0002\u0000\u0002\u0000t\u0000\u0000\u0000\u0006\u0001\u0000\u0000\u0000\u0000"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_1 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_1
new file mode 100644
index 0000000..26711ab
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/req_1
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/devices/3b0a0626-2dc4-44a3-ac5a-34d58b7b2a26/assign_blueprint",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "35"
+ ],
+ "Content-Type": [
+ "application/json"
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "{\"blueprint_id\":\"new_blueprint_id\"}"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_0
new file mode 100644
index 0000000..deb78ad
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_0
@@ -0,0 +1 @@
+{ "blueprint": {"id": "new_blueprint_id"} }
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_1 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_1
new file mode 100644
index 0000000..f134b3a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_path/resp_1
@@ -0,0 +1 @@
+{"blueprint": "assigned by path"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/cmd.tmpl
new file mode 100644
index 0000000..0097e3a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device change-blueprint --connection my-conn --device-id 3b0a0626-2dc4-44a3-ac5a-34d58b7b2a26 --blueprint-path ./testdata/blueprints/bp.zip
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/out b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/out
new file mode 100644
index 0000000..c815626
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/out
@@ -0,0 +1 @@
+{"blueprint": "assigned by zip"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_0
new file mode 100644
index 0000000..7175cac
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_0
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/blueprints/upload",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "14"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "blueprint.zip\n"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_1 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_1
new file mode 100644
index 0000000..8e5669b
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/req_1
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/devices/3b0a0626-2dc4-44a3-ac5a-34d58b7b2a26/assign_blueprint",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "40"
+ ],
+ "Content-Type": [
+ "application/json"
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "{\"blueprint_id\":\"blueprint_id_from_zip\"}"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_0
new file mode 100644
index 0000000..34acd0a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_0
@@ -0,0 +1 @@
+{ "blueprint": {"id": "blueprint_id_from_zip"} }
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_1 b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_1
new file mode 100644
index 0000000..c815626
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_by_zip/resp_1
@@ -0,0 +1 @@
+{"blueprint": "assigned by zip"}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/cmd.tmpl
new file mode 100644
index 0000000..18c0433
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device change-blueprint --connection my-conn --device-id 427ec09e-ec1e-4760-acc1-50106533b875
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/out b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/out
new file mode 100644
index 0000000..8ccadaf
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_change_blueprint_without_blueprint/out
@@ -0,0 +1 @@
+app exit with error: one of --blueprint-id or --blueprint-path must be specified
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/cmd.tmpl
new file mode 100644
index 0000000..555e0c4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device create lua-device --connection my-conn --runtime-id my-runtime --device-name my-device --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964 --blueprint-path ./testdata/blueprints/bp.zip
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/out b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/out
new file mode 100644
index 0000000..dc69131
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_blueprint_id_and_path/out
@@ -0,0 +1 @@
+app exit with error: only one of --blueprint-id or --blueprint-path can be specified
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/cmd.tmpl
new file mode 100644
index 0000000..d291224
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}} --site-id my-site
+enapter3 device create lua-device --connection my-conn --runtime-id my-runtime --device-name my-device --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/out
new file mode 100644
index 0000000..dc63955
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/out
@@ -0,0 +1 @@
+{"device":{"id":"1b6adca2-3a9f-4bfe-95b4-92b28a581055"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_0
new file mode 100644
index 0000000..34f782b
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site/devices/my-runtime",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_1 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_1
new file mode 100644
index 0000000..9a91017
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/req_1
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/provisioning/lua_device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "136"
+ ],
+ "Content-Type": [
+ "application/json"
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "{\"blueprint_id\":\"cdd82438-dda8-4f69-aad1-0be9adeab964\",\"name\":\"my-device\",\"runtime_id\":\"427ec09e-ec1e-4760-acc1-50106533b875\",\"slug\":\"\"}"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_1 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_1
new file mode 100644
index 0000000..dc63955
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_with_site_id/resp_1
@@ -0,0 +1 @@
+{"device":{"id":"1b6adca2-3a9f-4bfe-95b4-92b28a581055"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/cmd.tmpl
new file mode 100644
index 0000000..fa50e46
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device create lua-device --connection my-conn --runtime-id my-runtime --device-name my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/out b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/out
new file mode 100644
index 0000000..8ccadaf
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_blueprint/out
@@ -0,0 +1 @@
+app exit with error: one of --blueprint-id or --blueprint-path must be specified
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..a8bd9e8
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device create lua-device --connection my-conn --runtime-id my-runtime --device-name my-device --blueprint-id cdd82438-dda8-4f69-aad1-0be9adeab964
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/out
new file mode 100644
index 0000000..dc63955
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/out
@@ -0,0 +1 @@
+{"device":{"id":"1b6adca2-3a9f-4bfe-95b4-92b28a581055"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/req_0
new file mode 100644
index 0000000..569c3ba
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/req_0
@@ -0,0 +1,22 @@
+{
+ "Method": "POST",
+ "URL": "/v3/provisioning/lua_device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Length": [
+ "110"
+ ],
+ "Content-Type": [
+ "application/json"
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": "{\"blueprint_id\":\"cdd82438-dda8-4f69-aad1-0be9adeab964\",\"name\":\"my-device\",\"runtime_id\":\"my-runtime\",\"slug\":\"\"}"
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/resp_0
new file mode 100644
index 0000000..dc63955
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_lua_without_site_id/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"1b6adca2-3a9f-4bfe-95b4-92b28a581055"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..88631d2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device create standalone --connection my-conn --device-name my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/out
new file mode 100644
index 0000000..9789fa3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_create_standalone_without_site_id/out
@@ -0,0 +1 @@
+app exit with error: site ID is required
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/cmd.tmpl
new file mode 100644
index 0000000..ac61885
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device get --connection my-conn --site-id my-site --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/req_0
new file mode 100644
index 0000000..99cb7dd
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_with_site_id/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..713bef9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 device get --connection my-conn --device-id my-device
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/out
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/out
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/req_0
new file mode 100644
index 0000000..e16fe8e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/devices/my-device",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/resp_0
new file mode 100644
index 0000000..9e7cb4d
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/device_get_without_site_id/resp_0
@@ -0,0 +1 @@
+{"device":{"id":"427ec09e-ec1e-4760-acc1-50106533b875"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/cmd.tmpl
new file mode 100644
index 0000000..0988d20
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 rule-engine get --connection my-conn --site-id my-site
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/out
new file mode 100644
index 0000000..3fbd0b3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/out
@@ -0,0 +1 @@
+{"engine":{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/req_0
new file mode 100644
index 0000000..b6b1edd
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site/rule_engine",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/resp_0
new file mode 100644
index 0000000..3fbd0b3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_with_site_id/resp_0
@@ -0,0 +1 @@
+{"engine":{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..7e61b91
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 rule-engine get --connection my-conn
diff --git a/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/out
new file mode 100644
index 0000000..9789fa3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/rule_engine_get_without_site_id/out
@@ -0,0 +1 @@
+app exit with error: site ID is required
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/cmd.tmpl
new file mode 100644
index 0000000..fff957c
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 site get --connection my-conn --site-id my-site
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/out
new file mode 100644
index 0000000..86a96ed
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/out
@@ -0,0 +1 @@
+{"site":{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/req_0
new file mode 100644
index 0000000..619b9c4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/resp_0
new file mode 100644
index 0000000..86a96ed
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_with_site_id/resp_0
@@ -0,0 +1 @@
+{"site":{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..7ff9adf
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 site get --connection my-conn
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/out
new file mode 100644
index 0000000..9789fa3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_get_without_site_id/out
@@ -0,0 +1 @@
+app exit with error: site ID is required
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/cmd.tmpl
new file mode 100644
index 0000000..b0fdd24
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}} --site-id my-site
+enapter3 site list --connection my-conn
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/out
new file mode 100644
index 0000000..87fda06
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/out
@@ -0,0 +1,2 @@
+WARNING: trying to get sites list when site ID is set for current connection, result will contain only one site.
+{"sites":[{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}],"total_count":1}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/req_0
new file mode 100644
index 0000000..619b9c4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites/my-site",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/resp_0
new file mode 100644
index 0000000..86a96ed
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_with_site_id/resp_0
@@ -0,0 +1 @@
+{"site":{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/cmd.tmpl
new file mode 100644
index 0000000..62a108c
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 site list --connection my-conn
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/out b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/out
new file mode 100644
index 0000000..402bf28
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/out
@@ -0,0 +1 @@
+{"sites":[{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}],"total_count":1}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_0 b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_0
new file mode 100644
index 0000000..07fca04
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_0
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites?limit=50\u0026offset=0",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_1 b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_1
new file mode 100644
index 0000000..f10ffb3
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/req_1
@@ -0,0 +1,19 @@
+{
+ "Method": "GET",
+ "URL": "/v3/sites?limit=50\u0026offset=1",
+ "Header": {
+ "Accept-Encoding": [
+ "gzip"
+ ],
+ "Content-Type": [
+ ""
+ ],
+ "User-Agent": [
+ "enapter-cli/"
+ ],
+ "X-Enapter-Auth-Token": [
+ "enapter_api_test_token"
+ ]
+ },
+ "Body": ""
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_0
new file mode 100644
index 0000000..5b3b589
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_0
@@ -0,0 +1 @@
+{"sites":[{"id":"7cbdf086-4555-428d-9264-29c3052d71dd"}],"total_count":1}
diff --git a/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_1 b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_1
new file mode 100644
index 0000000..d27230f
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/site_list_without_site_id/resp_1
@@ -0,0 +1 @@
+{"sites":[],"total_count":1}
diff --git a/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/input b/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/input
deleted file mode 100644
index 37f6889..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/input
+++ /dev/null
@@ -1 +0,0 @@
-{"type":"disconnect","reason":"unauthorized","reconnect":false}
diff --git a/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/output b/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/output
deleted file mode 100644
index 1c44604..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/disconnect/invalid_token/output
+++ /dev/null
@@ -1 +0,0 @@
-[connection] disconnected with reason: unauthorized
diff --git a/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/input b/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/input
deleted file mode 100644
index 3ba1f47..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/input
+++ /dev/null
@@ -1,5 +0,0 @@
-{"type":"welcome"}
-
-{"identifier":"{\"channel\":\"RuleChannel\",\"rule_id\":\"SIM-RULE\"}","type":"message","message":{"topic":"error","payload":"Rule not found"}}
-
-{"identifier":"{\"channel\":\"RuleChannel\",\"rule_id\":\"SIM-RULE\"}","type":"reject_subscription"}
diff --git a/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/output b/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/output
deleted file mode 100644
index 41e8300..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/disconnect/rule_not_found/output
+++ /dev/null
@@ -1,3 +0,0 @@
-[connection] welcome
-[error] Rule not found
-[connection] disconnected
diff --git a/internal/app/enaptercli/testdata/rules_logs/simple/input b/internal/app/enaptercli/testdata/rules_logs/simple/input
deleted file mode 100644
index b2317d9..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/simple/input
+++ /dev/null
@@ -1,14 +0,0 @@
-{"type":"welcome"}
-{"identifier":"{\"channel\":\"RuleChannel\",\"rule_id\":\"SIM-RULE\"}","type":"confirm_subscription"}
-
-{"type":"ping","message":"1606485743"}
-
-{"type":"message","identifier":"{\"channel\":\"RuleChannel\",\"rule_id\":\"SIM-OTHER\"}","message":{"topic":"info","payload":"{\"timestamp\":1606485747,\"status\":\"ok\"}"}}
-
-{"type":"ping","message":"1606485746"}
-
-{"type":"message","identifier":"{\"channel\":\"OtherChannel\",\"rule_id\":\"SIM-RULE\"}","message":{"topic":"info","payload":"{\"timestamp\":1606485747,\"status\":\"ok\"}"}}
-
-{"type":"ping","message":"1606485749"}
-
-{"type":"message","identifier":"{\"rule_id\":\"SIM-RULE\",\"channel\":\"RuleChannel\"}","message":{"topic":"info","payload":"{\"timestamp\":1606486092,\"status\":\"ok\"}"}}
diff --git a/internal/app/enaptercli/testdata/rules_logs/simple/output b/internal/app/enaptercli/testdata/rules_logs/simple/output
deleted file mode 100644
index d03a3b3..0000000
--- a/internal/app/enaptercli/testdata/rules_logs/simple/output
+++ /dev/null
@@ -1,5 +0,0 @@
-[connection] welcome
-[connection] confirm_subscription
-[read_error] skip message with unknown identifier map[channel:RuleChannel rule_id:SIM-OTHER]
-[read_error] skip message with unknown identifier map[channel:OtherChannel rule_id:SIM-RULE]
-[info] {"timestamp":1606486092,"status":"ok"}
diff --git a/internal/app/enaptercli/testdata/rules_update/errors/output b/internal/app/enaptercli/testdata/rules_update/errors/output
deleted file mode 100644
index e64ea7a..0000000
--- a/internal/app/enaptercli/testdata/rules_update/errors/output
+++ /dev/null
@@ -1,3 +0,0 @@
-[ERROR] hmm... wait a minute
-[ERROR] oops!
-app exit with error: request execution failed
diff --git a/internal/app/enaptercli/testdata/rules_update/errors/requests b/internal/app/enaptercli/testdata/rules_update/errors/requests
deleted file mode 100644
index 462e2c2..0000000
--- a/internal/app/enaptercli/testdata/rules_update/errors/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"mutation($input:UpdateInput!){rule{update(input: $input){data{code,message,title},errors{code,message,path,title}}}}","variables":{"input":{"ruleId":"SIM-RULE","luaCode":"-- Rule\n"}}}
diff --git a/internal/app/enaptercli/testdata/rules_update/errors/responses b/internal/app/enaptercli/testdata/rules_update/errors/responses
deleted file mode 100644
index 2167716..0000000
--- a/internal/app/enaptercli/testdata/rules_update/errors/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"rule":{"update":{"data":null,"errors":[{"code":"warning","message":"hmm... wait a minute","title":"Started"},{"code":"fatal","message":"oops!","title":"Started"}]}}}}
diff --git a/internal/app/enaptercli/testdata/rules_update/errors/rule.lua b/internal/app/enaptercli/testdata/rules_update/errors/rule.lua
deleted file mode 100644
index 1be38ac..0000000
--- a/internal/app/enaptercli/testdata/rules_update/errors/rule.lua
+++ /dev/null
@@ -1 +0,0 @@
--- Rule
diff --git a/internal/app/enaptercli/testdata/rules_update/errors/settings.json b/internal/app/enaptercli/testdata/rules_update/errors/settings.json
deleted file mode 100644
index f429bfc..0000000
--- a/internal/app/enaptercli/testdata/rules_update/errors/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "rule_id": "SIM-RULE",
- "rule_path": "rule.lua"
-}
diff --git a/internal/app/enaptercli/testdata/rules_update/simple/output b/internal/app/enaptercli/testdata/rules_update/simple/output
deleted file mode 100644
index cdc1f76..0000000
--- a/internal/app/enaptercli/testdata/rules_update/simple/output
+++ /dev/null
@@ -1 +0,0 @@
-Rule successfully updated
diff --git a/internal/app/enaptercli/testdata/rules_update/simple/requests b/internal/app/enaptercli/testdata/rules_update/simple/requests
deleted file mode 100644
index 462e2c2..0000000
--- a/internal/app/enaptercli/testdata/rules_update/simple/requests
+++ /dev/null
@@ -1 +0,0 @@
-{"query":"mutation($input:UpdateInput!){rule{update(input: $input){data{code,message,title},errors{code,message,path,title}}}}","variables":{"input":{"ruleId":"SIM-RULE","luaCode":"-- Rule\n"}}}
diff --git a/internal/app/enaptercli/testdata/rules_update/simple/responses b/internal/app/enaptercli/testdata/rules_update/simple/responses
deleted file mode 100644
index 48b742f..0000000
--- a/internal/app/enaptercli/testdata/rules_update/simple/responses
+++ /dev/null
@@ -1 +0,0 @@
-{"data":{"rule":{"update":{"data":{"message":"Rule successfully updated"}}}}}
diff --git a/internal/app/enaptercli/testdata/rules_update/simple/rule.lua b/internal/app/enaptercli/testdata/rules_update/simple/rule.lua
deleted file mode 100644
index 1be38ac..0000000
--- a/internal/app/enaptercli/testdata/rules_update/simple/rule.lua
+++ /dev/null
@@ -1 +0,0 @@
--- Rule
diff --git a/internal/app/enaptercli/testdata/rules_update/simple/settings.json b/internal/app/enaptercli/testdata/rules_update/simple/settings.json
deleted file mode 100644
index f429bfc..0000000
--- a/internal/app/enaptercli/testdata/rules_update/simple/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "rule_id": "SIM-RULE",
- "rule_path": "rule.lua"
-}
diff --git a/internal/cloudapi/client.go b/internal/cloudapi/client.go
deleted file mode 100644
index b1c8a22..0000000
--- a/internal/cloudapi/client.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package cloudapi
-
-import (
- "fmt"
- "io"
- "net/http"
-
- "github.com/shurcooL/graphql"
-)
-
-type Client struct {
- client *graphql.Client
-}
-
-func NewClientWithURL(httpClient *http.Client, host string) *Client {
- if httpClient == nil {
- httpClient = http.DefaultClient
- }
- return &Client{
- client: graphql.NewClient(host, httpClient),
- }
-}
-
-type CredentialsTransport struct {
- tripper http.RoundTripper
- token string
- version string
-}
-
-func NewCredentialsTransport(t http.RoundTripper, token, version string) http.RoundTripper {
- return CredentialsTransport{
- tripper: t,
- token: token,
- version: version,
- }
-}
-
-func (t CredentialsTransport) RoundTrip(r *http.Request) (*http.Response, error) {
- newReq := new(http.Request)
- *newReq = *r
-
- newReq.Header = make(http.Header, len(r.Header))
- for k, s := range r.Header {
- newReq.Header[k] = s
- }
-
- newReq.Header.Set("Authorization", "Bearer "+t.token)
- newReq.Header.Set("X-ENAPTER-CLI-VERSION", t.version)
-
- return t.tripper.RoundTrip(newReq)
-}
-
-type CLIMessageWriterTransport struct {
- tripper http.RoundTripper
- writer io.Writer
-}
-
-func NewCLIMessageWriterTransport(t http.RoundTripper, w io.Writer) http.RoundTripper {
- return CLIMessageWriterTransport{
- tripper: t,
- writer: w,
- }
-}
-
-func (t CLIMessageWriterTransport) RoundTrip(r *http.Request) (*http.Response, error) {
- resp, err := t.tripper.RoundTrip(r)
- if err != nil {
- return nil, err
- }
-
- if msg := resp.Header.Get("X-ENAPTER-CLI-MESSAGE"); msg != "" {
- fmt.Fprintln(t.writer, msg)
- }
-
- return resp, nil
-}
diff --git a/internal/cloudapi/devices.go b/internal/cloudapi/devices.go
deleted file mode 100644
index 0df313d..0000000
--- a/internal/cloudapi/devices.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package cloudapi
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/shurcooL/graphql"
-)
-
-type UploadBlueprintData struct {
- Code string
- Message string
- Title string
- OperationID string
-}
-
-type UploadBlueprintError struct {
- Code string
- Message string
- Path []string
- Title string
-}
-
-type uploadBlueprintMutation struct {
- Device struct {
- UploadBlueprint struct {
- Data UploadBlueprintData
- Errors []UploadBlueprintError
- } `graphql:"uploadBlueprint(input: $input)"`
- }
-}
-
-func (c *Client) UploadBlueprint(
- ctx context.Context, hardwareID string, blueprint []byte,
-) (UploadBlueprintData, []UploadBlueprintError, error) {
- type UploadBlueprintInput struct {
- Blueprint graphql.String `json:"blueprint"`
- HardwareID graphql.ID `json:"hardwareId"`
- }
-
- variables := map[string]interface{}{
- "input": UploadBlueprintInput{
- Blueprint: graphql.String(blueprint),
- HardwareID: graphql.String(hardwareID),
- },
- }
-
- var mutation uploadBlueprintMutation
- if err := c.client.Mutate(ctx, &mutation, variables); err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- err = ErrRequestTimedOut
- }
- return UploadBlueprintData{}, nil, fmt.Errorf("mutate: %w", err)
- }
-
- uploadInfo := mutation.Device.UploadBlueprint
- return uploadInfo.Data, uploadInfo.Errors, nil
-}
-
-type OperationLog struct {
- Payload string
- CreatedAt string
- Severity string
-}
-
-type blueprintUpdateOperationQuery struct {
- Device *struct {
- BlueprintUpdateOperation *struct {
- Status string
- Logs struct {
- Edges []struct {
- Cursor graphql.String
- Node OperationLog
- }
- } `graphql:"logs(after: $after_cursor)"`
- } `graphql:"blueprintUpdateOperation(id: $operation_id)"`
- } `graphql:"device(hardwareId: $hardware_id)"`
-}
-
-func (c *Client) WriteOperationLogs(
- ctx context.Context, hardwareID, operationID string,
- writeLog func(operationID string, log OperationLog),
-) error {
- v := map[string]interface{}{
- "after_cursor": graphql.String(""),
- "hardware_id": hardwareID,
- "operation_id": operationID,
- }
-
- for {
- var q blueprintUpdateOperationQuery
- if err := c.client.Query(ctx, &q, v); err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- err = ErrRequestTimedOut
- }
- return fmt.Errorf("failed to send request: %w", err)
- }
-
- if q.Device == nil {
- return fmt.Errorf("%w: device not found", ErrFinishedWithError)
- }
-
- if q.Device.BlueprintUpdateOperation == nil {
- return fmt.Errorf("%w: operation not found", ErrFinishedWithError)
- }
-
- status := q.Device.BlueprintUpdateOperation.Status
- logs := q.Device.BlueprintUpdateOperation.Logs
-
- for _, e := range logs.Edges {
- v["after_cursor"] = e.Cursor
- writeLog(operationID, e.Node)
- }
-
- if len(logs.Edges) == 0 {
- switch status {
- case "SUCCEEDED":
- return nil
- case "ERROR":
- return ErrLogStatusError
- }
- }
-
- const logRequestPeriod = 100 * time.Millisecond
- time.Sleep(logRequestPeriod)
- }
-}
-
-type blueprintUpdateOperationsQuery struct {
- Device *struct {
- BlueprintUpdateOperations struct {
- Nodes []struct {
- ID string
- }
- } `graphql:"blueprintUpdateOperations(last: $last_int)"`
- } `graphql:"device(hardwareId: $hardware_id)"`
-}
-
-func (c *Client) WriteLastOperationsLogs(
- ctx context.Context, hardwareID string, lastOperationsNumber int,
- writeLog func(operationID string, log OperationLog),
-) error {
- v := map[string]interface{}{
- "hardware_id": hardwareID,
- "last_int": graphql.Int(lastOperationsNumber),
- }
-
- var q blueprintUpdateOperationsQuery
- if err := c.client.Query(ctx, &q, v); err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- err = ErrRequestTimedOut
- }
- return fmt.Errorf("failed to send request: %w", err)
- }
-
- if q.Device == nil {
- return fmt.Errorf("%w: device not found", ErrFinishedWithError)
- }
-
- for _, op := range q.Device.BlueprintUpdateOperations.Nodes {
- if err := c.WriteOperationLogs(ctx, hardwareID, op.ID, writeLog); err != nil {
- if errors.Is(err, ErrLogStatusError) {
- continue
- }
- return err
- }
- }
-
- return nil
-}
diff --git a/internal/cloudapi/errors.go b/internal/cloudapi/errors.go
deleted file mode 100644
index 559c7a6..0000000
--- a/internal/cloudapi/errors.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package cloudapi
-
-import "errors"
-
-var (
- ErrFinishedWithError = errors.New("request execution failed")
- ErrLogStatusError = errors.New("error during request execution")
- ErrRequestTimedOut = errors.New("request timed out")
- ErrFinished = errors.New("finished")
-)
diff --git a/internal/cloudapi/logs_writer.go b/internal/cloudapi/logs_writer.go
deleted file mode 100644
index 4ae69ee..0000000
--- a/internal/cloudapi/logs_writer.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package cloudapi
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/url"
- "sync"
-
- "github.com/gorilla/websocket"
-)
-
-const (
- fieldChannel = "channel"
- fieldHardwareID = "hardware_id"
- fieldRuleID = "rule_id"
-)
-
-type LogsWriter struct {
- url string
- identifier map[string]string
- writeLog func(topic, message string)
- wsConnMu sync.Mutex
- wsConn *websocket.Conn
-}
-
-func NewDeviceLogsWriter(
- host, token, apiVersion, hardwareID string,
- writer func(topic, message string),
-) (*LogsWriter, error) {
- identifier := map[string]string{
- fieldChannel: "DeviceChannel",
- fieldHardwareID: hardwareID,
- }
- return newLogsWriter(host, token, apiVersion, identifier, writer)
-}
-
-func NewRuleLogsWriter(
- host, token, apiVersion, ruleID string,
- writer func(topic, message string),
-) (*LogsWriter, error) {
- identifier := map[string]string{
- fieldChannel: "RuleChannel",
- fieldRuleID: ruleID,
- }
- return newLogsWriter(host, token, apiVersion, identifier, writer)
-}
-
-func newLogsWriter(
- host, token, apiVersion string, identifier map[string]string,
- writer func(topic, message string),
-) (*LogsWriter, error) {
- u, err := url.Parse(host)
- if err != nil {
- return nil, fmt.Errorf("parse url: %w", err)
- }
-
- q := u.Query()
- q.Set("token", token)
- q.Set("enapter_api_version", apiVersion)
- u.RawQuery = q.Encode()
-
- return &LogsWriter{
- url: u.String(),
- identifier: identifier,
- writeLog: writer,
- }, nil
-}
-
-func (l *LogsWriter) Run(ctx context.Context) error {
- defer l.close()
-
- go func() {
- <-ctx.Done()
- l.close()
- }()
-
- for {
- select {
- case <-ctx.Done():
- return nil
- default:
- }
-
- if err := l.connect(ctx); err != nil {
- l.writeLog("connection", fmt.Sprintf("failed to connect: %s", err.Error()))
- continue
- }
-
- if err := l.subscribe(); err != nil {
- l.writeLog("connection", fmt.Sprintf("failed to subscribe: %s", err.Error()))
- continue
- }
-
- if err := l.readAndWriteLogs(ctx); err != nil {
- if errors.Is(err, ErrFinished) {
- return nil
- }
- l.writeLog("read_error", fmt.Sprintf("failed to read msg: %s", err.Error()))
- return err
- }
- }
-}
-
-func (l *LogsWriter) connect(ctx context.Context) error {
- wsConn, resp, err := websocket.DefaultDialer.DialContext(ctx, l.url, nil)
- if err != nil {
- return fmt.Errorf("websockets dial: %w", err)
- }
- defer resp.Body.Close()
-
- l.wsConnMu.Lock()
- defer l.wsConnMu.Unlock()
- l.wsConn = wsConn
-
- return nil
-}
-
-func (l *LogsWriter) close() {
- l.wsConnMu.Lock()
- defer l.wsConnMu.Unlock()
-
- if l.wsConn != nil {
- l.wsConn.Close()
- }
-}
-
-func (l *LogsWriter) subscribe() error {
- identifierBytes, err := json.Marshal(l.identifier)
- if err != nil {
- return fmt.Errorf("failed to marshal sm: %w", err)
- }
-
- msg := struct {
- Command string `json:"command"`
- Identifier string `json:"identifier"`
- }{
- Command: "subscribe",
- Identifier: string(identifierBytes),
- }
-
- msgBytes, err := json.Marshal(&msg)
- if err != nil {
- return fmt.Errorf("failed to marshal m: %w", err)
- }
-
- err = l.wsConn.WriteMessage(websocket.TextMessage, msgBytes)
- if err != nil {
- return fmt.Errorf("failed to subscribe: %w", err)
- }
-
- return nil
-}
-
-func (l *LogsWriter) readAndWriteLogs(ctx context.Context) error {
- for {
- msgType, msgBytes, err := l.wsConn.ReadMessage()
- select {
- case <-ctx.Done():
- return nil
- default:
- }
-
- if err != nil {
- return err
- }
-
- if msgType != websocket.TextMessage {
- l.writeLog("read_error",
- fmt.Sprintf("skip unsupported message type [%d] (only text type [%d] supported)",
- msgType, websocket.TextMessage))
- continue
- }
-
- if err := l.process(msgBytes); err != nil {
- return err
- }
- }
-}
-
-type logsBaseMessage struct {
- Type string `json:"type"`
- Identifier string `json:"identifier"`
- Message json.RawMessage `json:"message"`
-}
-
-type logsMessage struct {
- Topic string `json:"topic"`
- Payload string `json:"payload"`
-}
-
-type disconnectMessage struct {
- Type string `json:"type"`
- Reason string `json:"reason"`
- Reconnect bool `json:"reconnect"`
-}
-
-func (l *LogsWriter) process(msgBytes []byte) error {
- baseMsg := logsBaseMessage{}
- if err := json.Unmarshal(msgBytes, &baseMsg); err != nil {
- errMsg := fmt.Sprintf("skip invalid message %s: %s", string(msgBytes), err.Error())
- l.writeLog("read_error", errMsg)
- return err
- }
-
- switch baseMsg.Type {
- case "ping":
- case "welcome", "confirm_subscription":
- l.writeLog("connection", baseMsg.Type)
- case "reject_subscription":
- l.writeLog("connection", "disconnected")
- return ErrFinished
- case "disconnect":
- return l.processDisconnect(msgBytes)
- case "message":
- l.processMessage(baseMsg)
- default:
- l.writeLog("unknown", string(msgBytes))
- }
-
- return nil
-}
-
-func (l *LogsWriter) processDisconnect(msgBytes []byte) error {
- var msg disconnectMessage
- if err := json.Unmarshal(msgBytes, &msg); err != nil {
- return nil
- }
- if msg.Reconnect {
- l.writeLog("connection",
- fmt.Sprintf("disconnected with reason: %s. Reconnecting...", msg.Reason))
- return nil
- }
- l.writeLog("connection",
- fmt.Sprintf("disconnected with reason: %s", msg.Reason))
- return ErrFinished
-}
-
-func (l *LogsWriter) processMessage(baseMsg logsBaseMessage) {
- var identifier map[string]string
- if err := json.Unmarshal([]byte(baseMsg.Identifier), &identifier); err != nil {
- l.writeLog("read_error",
- fmt.Sprintf("skip message with invalid identifier %s: %s", baseMsg.Identifier, err.Error()))
- return
- }
- if !mapsEqual(l.identifier, identifier) {
- l.writeLog("read_error",
- fmt.Sprintf("skip message with unknown identifier %+v", identifier))
- return
- }
-
- var msg logsMessage
- if err := json.Unmarshal(baseMsg.Message, &msg); err != nil {
- l.writeLog("read_error",
- fmt.Sprintf("skip invalid log message %s: %s", string(baseMsg.Message), err.Error()))
- return
- }
- l.writeLog(msg.Topic, msg.Payload)
-}
-
-func mapsEqual(m1, m2 map[string]string) bool {
- if len(m1) != len(m2) {
- return false
- }
- for k, v1 := range m1 {
- if v2, ok := m2[k]; !ok || v1 != v2 {
- return false
- }
- }
- return true
-}
diff --git a/internal/cloudapi/rules.go b/internal/cloudapi/rules.go
deleted file mode 100644
index bf1b42e..0000000
--- a/internal/cloudapi/rules.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package cloudapi
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/shurcooL/graphql"
-)
-
-type UpdateRuleInput struct {
- RuleID string
- LuaCode string
- StdlibVersion string
- ExecutionInterval int
-}
-
-type UpdateRuleData struct {
- Code string
- Message string
- Title string
-}
-
-type UpdateRuleError struct {
- Code string
- Message string
- Path []string
- Title string
-}
-
-func (c *Client) UpdateRule(
- ctx context.Context, input UpdateRuleInput,
-) (UpdateRuleData, []UpdateRuleError, error) {
- var mutation struct {
- Rule struct {
- Update struct {
- Data UpdateRuleData
- Errors []UpdateRuleError
- } `graphql:"update(input: $input)"`
- }
- }
-
- type UpdateInput struct {
- RuleID graphql.String `json:"ruleId"`
- LuaCode graphql.String `json:"luaCode"`
- StdlibVersion graphql.String `json:"stdlibVersion,omitempty"`
- ExecutionInterval graphql.Int `json:"executionInterval,omitempty"`
- }
-
- variables := map[string]interface{}{
- "input": UpdateInput{
- RuleID: graphql.String(input.RuleID),
- LuaCode: graphql.String(input.LuaCode),
- StdlibVersion: graphql.String(input.StdlibVersion),
- ExecutionInterval: graphql.Int(input.ExecutionInterval),
- },
- }
-
- if err := c.client.Mutate(ctx, &mutation, variables); err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
- err = ErrRequestTimedOut
- }
- return UpdateRuleData{}, nil, fmt.Errorf("mutate: %w", err)
- }
-
- return mutation.Rule.Update.Data, mutation.Rule.Update.Errors, nil
-}
diff --git a/internal/publichttp/client.go b/internal/publichttp/client.go
deleted file mode 100644
index effa227..0000000
--- a/internal/publichttp/client.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package publichttp
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "time"
-)
-
-const defaultBaseURL = "https://api.enapter.com"
-
-type Client struct {
- baseURL *url.URL
- client *http.Client
-
- // Services used for talking to different parts of the Enapter API.
- Commands CommandsAPI
-}
-
-func NewClient(httpClient *http.Client) *Client {
- c, err := NewClientWithURL(httpClient, defaultBaseURL)
- if err != nil {
- panic(err)
- }
- return c
-}
-
-func NewClientWithURL(httpClient *http.Client, baseURL string) (*Client, error) {
- if httpClient == nil {
- httpClient = http.DefaultClient
- }
-
- u, err := url.Parse(baseURL)
- if err != nil {
- return nil, err
- }
-
- c := &Client{baseURL: u, client: httpClient}
- c.Commands = CommandsAPI{client: c}
- return c, nil
-}
-
-func (c *Client) NewRequest(method, path string, body io.Reader) (*http.Request, error) {
- return c.NewRequestWithContext(context.Background(), method, path, body)
-}
-
-func (c *Client) NewRequestWithContext(
- ctx context.Context, method, path string, body io.Reader,
-) (*http.Request, error) {
- u, err := c.baseURL.Parse(path)
- if err != nil {
- return nil, fmt.Errorf("parse url: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
- if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
- }
-
- return req, err
-}
-
-func (c *Client) Do(req *http.Request) (*http.Response, error) {
- resp, err := c.client.Do(req)
- if err != nil {
- return nil, err
- }
-
- if resp.StatusCode >= http.StatusMultipleChoices {
- defer resp.Body.Close()
-
- responseError, err := c.parseResponseError(resp)
- if err != nil {
- return nil, err
- }
- return nil, responseError
- }
-
- return resp, nil
-}
-
-func (c *Client) parseResponseError(r *http.Response) (ResponseError, error) {
- var errors ResponseError
-
- if r.Body != http.NoBody {
- if err := json.NewDecoder(r.Body).Decode(&errors); err != nil {
- return ResponseError{}, fmt.Errorf("unmarshal body: %w", err)
- }
- }
-
- errors.StatusCode = r.StatusCode
- if retryAfter := r.Header.Get("Retry-After"); retryAfter != "" {
- duration, err := strconv.Atoi(retryAfter)
- if err != nil {
- return ResponseError{}, fmt.Errorf("parse Retry-After: %w", err)
- }
- errors.RetryAfter = time.Duration(duration) * time.Second
- }
-
- return errors, nil
-}
diff --git a/internal/publichttp/commands.go b/internal/publichttp/commands.go
deleted file mode 100644
index 24eae76..0000000
--- a/internal/publichttp/commands.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package publichttp
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strconv"
-)
-
-type CommandsAPI struct {
- client *Client
-}
-
-type CommandQuery struct {
- HardwareID string `json:"hardware_id"`
- CommandName string `json:"command_name"`
- Arguments map[string]interface{} `json:"arguments"`
-}
-
-type CommandResponse struct {
- State CommandState `json:"state"`
- Payload map[string]interface{} `json:"payload,omitempty"`
-}
-
-type CommandState string
-
-const (
- CommandSucceeded CommandState = "succeeded"
- CommandError CommandState = "error"
- CommandPlatformError CommandState = "platform_error"
- CommandStarted CommandState = "started"
- CommandInProgress CommandState = "device_in_progress"
-)
-
-func (c *CommandsAPI) Execute(
- ctx context.Context, query CommandQuery,
-) (CommandResponse, error) {
- resp, err := c.execute(ctx, query, false)
- if err != nil {
- return CommandResponse{}, err
- }
- defer resp.Body.Close()
-
- var cmdResp CommandResponse
- if err := json.NewDecoder(resp.Body).Decode(&cmdResp); err != nil {
- return CommandResponse{}, fmt.Errorf("unmarshal response: %w", err)
- }
-
- return cmdResp, nil
-}
-
-type CommandProgress struct {
- CommandResponse
- Error error
-}
-
-func (c *CommandsAPI) ExecuteWithProgress(
- ctx context.Context, query CommandQuery,
-) (<-chan CommandProgress, error) {
- //nolint:bodyclose // closed in the reading goroutine
- resp, err := c.execute(ctx, query, true)
- if err != nil {
- return nil, err
- }
-
- progressCh := make(chan CommandProgress)
- go func() {
- defer resp.Body.Close()
- defer close(progressCh)
-
- scanner := bufio.NewScanner(resp.Body)
- for scanner.Scan() {
- var p CommandProgress
- p.Error = json.Unmarshal(scanner.Bytes(), &p.CommandResponse)
-
- select {
- case <-ctx.Done():
- return
- case progressCh <- p:
- }
- }
- }()
-
- return progressCh, nil
-}
-
-func (c *CommandsAPI) execute(
- ctx context.Context, query CommandQuery, showProgress bool,
-) (*http.Response, error) {
- queryBody := new(bytes.Buffer)
- if err := json.NewEncoder(queryBody).Encode(query); err != nil {
- return nil, fmt.Errorf("marshal body: %w", err)
- }
-
- const path = "/commands/v1/execute"
- req, err := c.client.NewRequestWithContext(ctx, http.MethodPost, path, queryBody)
- if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
- }
-
- values := req.URL.Query()
- values.Set("show_progress", strconv.FormatBool(showProgress))
- req.URL.RawQuery = values.Encode()
-
- resp, err := c.client.Do(req)
- if err != nil {
- return nil, err
- }
-
- return resp, nil
-}
diff --git a/internal/publichttp/errors.go b/internal/publichttp/errors.go
deleted file mode 100644
index 297992b..0000000
--- a/internal/publichttp/errors.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package publichttp
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-type ResponseError struct {
- Errors []Error `json:"errors"`
- StatusCode int `json:"-"`
- RetryAfter time.Duration `json:"-"`
-}
-
-func (r ResponseError) Error() string {
- var builder strings.Builder
- for i, e := range r.Errors {
- if i > 0 {
- builder.WriteByte('\n')
- }
- builder.WriteString(fmt.Sprintf("%v: %v", e.Code, e.Message))
- }
- return builder.String()
-}
-
-type Error struct {
- Code string `json:"code"`
- Message string `json:"message"`
- Details map[string]interface{} `json:"details,omitempty"`
-}
diff --git a/internal/publichttp/transport.go b/internal/publichttp/transport.go
deleted file mode 100644
index d5892fd..0000000
--- a/internal/publichttp/transport.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package publichttp
-
-import "net/http"
-
-type AuthTokenTransport struct {
- token string
- next http.RoundTripper
-}
-
-func NewAuthTokenTransport(t http.RoundTripper, token string) http.RoundTripper {
- return &AuthTokenTransport{token: token, next: t}
-}
-
-func (t *AuthTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- const header = "X-Enapter-Auth-Token"
- s := cloneRequest(req)
- s.Header.Set(header, t.token)
- return t.next.RoundTrip(s)
-}
-
-func cloneRequest(req *http.Request) *http.Request {
- shallow := new(http.Request)
- *shallow = *req
- for k, s := range req.Header {
- shallow.Header[k] = s
- }
- return shallow
-}