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..6832068 100644
--- a/README.md
+++ b/README.md
@@ -3,19 +3,51 @@
[](/LICENSE)
[](https://github.com/enapter/enapter-cli/releases/latest)
+## Overview
-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.
+The Enapter CLI is a command-line interface tool for managing Enapter services, including sites, devices, blueprints, and the rule engine. It provides a comprehensive set of commands for interacting with the Enapter Cloud platform and Gateway 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
+```
+
+## How to upgrade
+
+### macOS - recommended
+
+Version 1:
+
+```bash
+brew upgrade enapter
+```
+
+Version 3:
+
+```bash
+brew upgrade enapter@3
+```
+
### Get prebuilt binaries
Choose your platform and required release on the [Releases page](https://github.com/Enapter/enapter-cli/releases).
@@ -32,7 +64,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
@@ -52,8 +87,79 @@ Enapter CLI requires access token for authentication. Obtaining of the token is
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:
+
+### Authentication
+
+The Enapter CLI requires an access token for authentication. You can obtain your access token from your Enapter Cloud account settings at [Enapter Cloud](https://cloud.enapter.com).
+
+### Setting Up Your First Enapter Cloud Connection
+
+The recommended way to use the Enapter CLI is by setting up named connections. This approach allows you to:
+- Manage multiple environments (production, staging, development)
+- Switch between Enapter Cloud and Gateway connections easily
+- Associate connections with specific sites
+- Store configuration securely
+
+**Step 1: Add a connection**
+
+```bash
+enapter connection add --name my-cloud --token YOUR_ACCESS_TOKEN
+```
+
+**Step 2: Set it as default (optional)**
+
+```bash
+enapter connection set-default --name my-cloud
+```
+
+**Step 3: Verify the connection**
+
+```bash
+enapter connection list
+```
+
+### Quick Start Examples
+
+Once your connection is set up, you can start managing your Enapter resources:
+
+**For Enapter Cloud connections:**
+
+```bash
+# List all sites
+enapter site list
+
+# List all devices for a specific site
+enapter device list --site-id SITE_ID
+
+# Get device information
+enapter device get --site-id SITE_ID --device-id DEVICE_ID
+
+# Upload a blueprint (from file or directory)
+enapter blueprint upload --path ./my-blueprint.enbp
+# or
+enapter blueprint upload --path ./my-blueprint/
+
+# Create a new Lua device
+enapter device create lua-device \
+ --site-id SITE_ID \
+ --runtime-id UCM_DEVICE_ID \
+ --device-name "My Device" \
+ --device-slug my-device \
+ --blueprint-path ./blueprint/ # or ./blueprint.enbp
+```
+
### 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.
+>
+> For Version 3. Please follow enable `Dev mode` and use [https://github.com/nkrasko/autocomplete](https://github.com/nkrasko/autocomplete) repository until merge request is accepted.
+
+In order to make life easier with command line interface, you may use [Kiro CLI](https://kiro.dev/cli/). This autocompletion tool has native support for the Enapter CLI for Mac OS X and Linux.
+
+
+
+### Documentation
-
\ No newline at end of file
+You can find extended documentation in [Enapter CLI 3 Reference](./enapter-cli-3-reference.md)
\ 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/enapter-cli-3-reference.md b/enapter-cli-3-reference.md
new file mode 100644
index 0000000..590b685
--- /dev/null
+++ b/enapter-cli-3-reference.md
@@ -0,0 +1,1511 @@
+# Enapter CLI 3 Reference
+
+## Overview
+
+The Enapter CLI is a command-line interface tool for managing Enapter energy management services, including sites, devices, blueprints, and the rule engine. It provides a comprehensive set of commands for interacting with the Enapter Cloud platform and Gateway devices.
+
+## Getting Started
+
+### Authentication
+
+The Enapter CLI requires an access token for authentication. You can obtain your access token from your Enapter Cloud account settings at [Enapter Cloud](https://cloud.enapter.com).
+
+### Setting Up Your First Enapter Cloud Connection
+
+The recommended way to use the Enapter CLI is by setting up named connections. This approach allows you to:
+- Manage multiple environments (production, staging, development)
+- Switch between Enapter Cloud and Gateway connections easily
+- Associate connections with specific sites
+- Store configuration securely
+
+**Step 1: Add a Enapter Cloud connection**
+
+```bash
+enapter3 connection add --name my-cloud --token YOUR_ACCESS_TOKEN
+```
+
+**Step 2: Set it as default (optional)**
+
+```bash
+enapter3 connection set-default --name my-cloud
+```
+
+**Step 3: Verify the connection**
+
+```bash
+enapter3 connection list
+```
+
+### Setting Up Your First Enapter Gateway Connection
+
+**Step 1: Navigate to your Enapter Gateway 3.0 Web Interface `System Settings` page by using Gateway IP address or mDNS name http://enapter-gateway.local/settings**
+
+**Step 2: Enter your Enapter Gateway password**
+
+**Step 3: Click `API Token` and copy token to clipboard**
+
+**Step 4: Add a named connection**
+
+ ```bash
+ enapter3 connection add --gateway \
+ --name my-gateway \
+ --url http://GATEWAY_IP/api \
+ --token GATEWAY_API_TOKEN \
+ --allow-insecure
+ ```
+**Step 5: Set it as default (optional)**
+
+ ```bash
+ enapter3 connection set-default --name my-gateway
+ ```
+
+### Quick Start Examples
+
+Once your connection is set up, you can start managing your Enapter resources:
+
+**For Enapter Cloud connections:**
+
+```bash
+# List all sites
+enapter3 site list
+
+# List all devices for a specific site
+enapter3 device list --site-id SITE_ID
+
+# Get device information
+enapter3 device get --site-id SITE_ID --device-id DEVICE_ID
+
+# Upload a blueprint (from file or directory)
+enapter3 blueprint upload --path ./my-blueprint.enbp
+# or
+enapter3 blueprint upload --path ./my-blueprint/
+
+# Create a new Lua device
+enapter3 device create lua-device \
+ --site-id SITE_ID \
+ --runtime-id UCM_DEVICE_ID \
+ --device-name "My Device" \
+ --device-slug my-device \
+ --blueprint-path ./blueprint/ # or ./blueprint.enbp
+```
+
+**For Gateway connections (local-first):**
+
+```bash
+# List all devices (no site-id needed)
+enapter3 device list
+
+# Get device information
+enapter3 device get --device-id DEVICE_ID
+
+# Create a new Lua device
+enapter3 device create lua-device \
+ --runtime-id UCM_DEVICE_ID \
+ --device-name "My Device" \
+ --device-slug my-device \
+ --blueprint-path ./blueprint/ # or ./blueprint.enbp
+```
+
+## Connection Management
+
+The connection commands allow you to manage multiple connections to Enapter Cloud and Gateway devices.
+
+::: tip Cloud vs. Gateway
+**Important distinction:**
+- **Enapter Cloud connections** require `--site-id` parameter for most device, rule-engine, and site commands
+- **Gateway connections** work in local-first mode and do NOT require `--site-id` parameter
+
+You can create site-scoped Cloud connections using `--site-id` flag in `connection add` to avoid specifying it in every command.
+:::
+
+### connection add
+
+Add a new connection to Enapter Cloud or a Gateway.
+
+**Usage:**
+```bash
+enapter3 connection add [options]
+```
+
+**Options:**
+
+| Option | Description | Required |
+|--------|-------------|----------|
+| `--name` | Connection name | Yes |
+| `--token` | Enapter API access token | Yes |
+| `--url` | API base URL | No (default: https://api.enapter.com) |
+| `--gateway` | Connection is to a Gateway | No (default: false) |
+| `--site-id` | Limit connection to specific site (Cloud only) | No |
+| `--allow-insecure` | Allow insecure connections | No (default: false) |
+
+**Example:**
+```bash
+enapter3 connection add \
+ --name production \
+ --token abc123... \
+ --site-id site-456
+```
+
+### connection list
+
+List all configured connections.
+
+**Usage:**
+```bash
+enapter3 connection list
+```
+
+### connection remove
+
+Remove a connection.
+
+**Usage:**
+```bash
+enapter3 connection remove --name CONNECTION_NAME
+```
+
+### connection set-default
+
+Set the default connection for CLI operations.
+
+**Usage:**
+```bash
+enapter3 connection set-default --name CONNECTION_NAME
+```
+
+## Site Management
+
+Manage your Enapter sites.
+
+### Common Options
+
+Most site commands support these options:
+
+| Option | Description |
+|--------|-------------|
+| `--connection, -c` | Name of the connection to use |
+| `--api-allow-insecure` | Allow insecure connections |
+| `--verbose` | Log extra details about the operation |
+
+### site list
+
+List all user sites.
+
+**Usage:**
+```bash
+enapter3 site list [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--my-sites` | Show only sites where user is owner or installer |
+| `--limit` | Maximum number of sites to retrieve |
+
+**Example:**
+```bash
+# List all sites
+enapter3 site list
+
+# List only your sites
+enapter3 site list --my-sites
+
+# Limit results
+enapter3 site list --limit 10
+```
+
+### site get
+
+Retrieve detailed information about a specific site.
+
+**Usage:**
+```bash
+enapter3 site get --site-id SITE_ID
+```
+
+**Example:**
+```bash
+enapter3 site get --site-id 12345
+```
+
+## Device Management
+
+Comprehensive commands for managing Enapter devices.
+
+### Common Options
+
+Most device commands support these options:
+
+| Option | Description |
+|--------|-------------|
+| `--connection, -c` | Name of the connection to use |
+| `--site-id` | Site ID (auto-detected from connection if available) |
+| `--device-id, -d` | Device ID |
+| `--api-allow-insecure` | Allow insecure connections |
+| `--verbose` | Log extra details |
+
+### device list
+
+List all devices ordered by device ID.
+
+**Usage:**
+```bash
+enapter3 device list [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--expand` | Expand device information (connectivity, manifest, properties, communication, site) |
+| `--limit` | Maximum number of devices to retrieve |
+
+**Example:**
+```bash
+# List all devices
+enapter3 device list
+
+# List with expanded information
+enapter3 device list --expand connectivity --expand manifest
+
+# List devices for specific site
+enapter3 device list --site-id 12345
+```
+
+### device get
+
+Retrieve detailed information about a specific device.
+
+**Usage:**
+```bash
+enapter3 device get --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--expand` | Expand device information (connectivity, manifest, properties, communication, site) |
+
+**Example:**
+```bash
+enapter3 device get --device-id abc123 --expand properties --expand manifest
+```
+
+### device create standalone
+
+Create a new standalone device.
+
+**Usage:**
+```bash
+enapter3 device create standalone [options]
+```
+
+**Options:**
+
+| Option | Description | Required |
+|--------|-------------|----------|
+| `--site-id, -s` | Site ID where device will be created | Yes |
+| `--device-name, -n` | Name for the new device | Yes |
+| `--device-slug` | Slug for the device | Yes |
+
+**Example:**
+```bash
+enapter3 device create standalone \
+ --site-id 12345 \
+ --device-name "My Device" \
+ --device-slug my-device
+```
+
+### device create lua-device
+
+Create a new Lua device.
+
+**Usage:**
+```bash
+enapter3 device create lua-device [options]
+```
+
+**Options:**
+
+| Option | Description | Required |
+|--------|-------------|----------|
+| `--site-id` | Site ID | Yes |
+| `--runtime-id, -r` | UCM device ID where Lua device will run | Yes |
+| `--device-name, -n` | Name for the new device | Yes |
+| `--device-slug` | Slug for the device | Yes |
+| `--blueprint-id, -b` | Blueprint ID to use | Yes* |
+| `--blueprint-path` | Blueprint path (.enbp file or directory) | Yes* |
+
+*Either `--blueprint-id` or `--blueprint-path` is required.
+
+**Example:**
+```bash
+# Using blueprint file
+enapter3 device create lua-device \
+ --site-id 12345 \
+ --runtime-id ucm-789 \
+ --device-name "My Lua Device" \
+ --device-slug my-lua-device \
+ --blueprint-path ./my-blueprint.enbp
+
+# Using blueprint directory
+enapter3 device create lua-device \
+ --site-id 12345 \
+ --runtime-id ucm-789 \
+ --device-name "My Lua Device" \
+ --device-slug my-lua-device \
+ --blueprint-path ./my-blueprint/
+```
+
+### device update
+
+Update device properties.
+
+**Usage:**
+```bash
+enapter3 device update --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--name` | New device name |
+| `--slug` | New device slug |
+
+**Example:**
+```bash
+enapter3 device update --device-id abc123 --name "Updated Device Name"
+```
+
+### device delete
+
+Delete a device.
+
+**Usage:**
+```bash
+enapter3 device delete --device-id DEVICE_ID
+```
+
+**Example:**
+```bash
+enapter3 device delete --device-id abc123 --site-id 12345
+```
+
+### device change-blueprint
+
+Change the blueprint associated with a device.
+
+**Usage:**
+```bash
+enapter3 device change-blueprint --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description | Required |
+|--------|-------------|----------|
+| `--blueprint-id, -b` | Blueprint ID | Yes* |
+| `--blueprint-path` | Blueprint path (.enbp file or directory) | Yes* |
+
+*Either `--blueprint-id` or `--blueprint-path` is required.
+
+**Example:**
+```bash
+# Using blueprint ID
+enapter3 device change-blueprint --device-id abc123 --blueprint-id bp-456
+
+# Using local blueprint file
+enapter3 device change-blueprint --device-id abc123 --blueprint-path ./new-blueprint.enbp
+
+# Using local blueprint directory
+enapter3 device change-blueprint --device-id abc123 --blueprint-path ./new-blueprint/
+```
+
+### device logs
+
+Show device logs with filtering and streaming options.
+
+**Usage:**
+```bash
+enapter3 device logs --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--follow, -f` | Follow log output in real-time |
+| `--from` | From timestamp (RFC 3339 format) |
+| `--to` | To timestamp (RFC 3339 format) |
+| `--limit, -l` | Maximum number of logs to retrieve |
+| `--offset, -o` | Number of logs to skip |
+| `--severity, -s` | Filter by severity |
+| `--order` | Sort order (RECEIVED_AT_ASC, RECEIVED_AT_DESC) |
+| `--show` | Filter criteria (ALL, PERSISTED_ONLY, TEMPORARY_ONLY) |
+
+**Example:**
+```bash
+# Stream logs in real-time
+enapter3 device logs --device-id abc123 --follow
+
+# Get last 100 error logs
+enapter3 device logs --device-id abc123 --limit 100 --severity error
+
+# Get logs from specific time range
+enapter3 device logs --device-id abc123 \
+ --from 2024-01-01T00:00:00Z \
+ --to 2024-01-31T23:59:59Z
+```
+
+### device telemetry
+
+Show device telemetry data.
+
+**Usage:**
+```bash
+enapter3 device telemetry --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--follow, -f` | Follow telemetry output in real-time |
+
+**Example:**
+```bash
+# Stream telemetry in real-time
+enapter3 device telemetry --device-id abc123 --follow
+```
+
+### device monitor
+
+Monitor device traffic in real-time. This command allows you to observe all incoming and outgoing device communications.
+
+**Usage:**
+```bash
+enapter3 device monitor --device-id DEVICE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--include-runtime` | Monitor device's runtime traffic too (default: false) |
+
+**Example:**
+```bash
+# Monitor device traffic
+enapter3 device monitor --device-id abc123
+
+# Monitor device traffic including runtime
+enapter3 device monitor --device-id abc123 --include-runtime
+```
+
+### device run-terminal
+
+Open a remote terminal session to a Gateway device.
+
+::: warning
+Remote terminal feature must be enabled in gateway settings. Use `Ctrl+]` to force connection close.
+:::
+
+**Usage:**
+```bash
+enapter3 device run-terminal --device-id GATEWAY_ID
+```
+
+**Example:**
+```bash
+enapter3 device run-terminal --device-id gateway-123
+```
+
+### device command execute
+
+Execute a command on a device.
+
+**Usage:**
+```bash
+enapter3 device command execute --device-id DEVICE_ID --name COMMAND_NAME [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--name` | Command name |
+| `--arguments` | Command arguments (JSON string) |
+
+**Example:**
+```bash
+# Execute command without arguments
+enapter3 device command execute --device-id abc123 --name start
+
+# Execute command with arguments
+enapter3 device command execute --device-id abc123 \
+ --name set_temperature \
+ --arguments '{"value": 25.5}'
+```
+
+### device command list
+
+List command executions for a device.
+
+**Usage:**
+```bash
+enapter3 device command list --device-id DEVICE_ID
+```
+
+### device command get
+
+Retrieve information about a specific command execution.
+
+**Usage:**
+```bash
+enapter3 device command get --device-id DEVICE_ID --execution-id EXECUTION_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--expand` | Expand execution information (log) |
+
+**Example:**
+```bash
+enapter3 device command get \
+ --device-id abc123 \
+ --execution-id exec-456 \
+ --expand log
+```
+
+### device communication-config generate
+
+Generate a new communication configuration for a device.
+
+**Usage:**
+```bash
+enapter3 device communication-config generate --device-id DEVICE_ID --protocol PROTOCOL
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--protocol` | Connection protocol (MQTT, MQTTS) |
+
+**Example:**
+```bash
+enapter3 device communication-config generate \
+ --device-id abc123 \
+ --protocol MQTTS
+```
+
+## Blueprint Management
+
+Manage device blueprints for your Enapter devices.
+
+### Common Options
+
+Blueprint commands support these options:
+
+| Option | Description |
+|--------|-------------|
+| `--connection, -c` | Name of the connection to use |
+| `--api-allow-insecure` | Allow insecure connections |
+| `--verbose` | Log extra details |
+
+### blueprint get
+
+Retrieve blueprint metadata.
+
+**Usage:**
+```bash
+enapter3 blueprint get --blueprint-id BLUEPRINT_ID
+```
+
+**Example:**
+```bash
+enapter3 blueprint get --blueprint-id my-blueprint
+```
+
+### blueprint download
+
+Download a blueprint from the platform.
+
+**Usage:**
+```bash
+enapter3 blueprint download --blueprint-id BLUEPRINT_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--output, -o` | Output file name |
+
+**Example:**
+```bash
+enapter3 blueprint download \
+ --blueprint-id my-blueprint \
+ --output my-blueprint.enbp
+```
+
+### blueprint upload
+
+Upload a blueprint to the platform.
+
+**Usage:**
+```bash
+enapter3 blueprint upload --path PATH
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--path, -p` | Blueprint path (.enbp file or directory) |
+
+**Example:**
+```bash
+# Upload from enbp file
+enapter3 blueprint upload --path ./my-blueprint.enbp
+
+# Upload from directory
+enapter3 blueprint upload --path ./my-blueprint/
+```
+
+### blueprint profiles download
+
+Download blueprint profiles from the platform.
+
+**Usage:**
+```bash
+enapter3 blueprint profiles download [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--output, -o` | Output file name |
+
+**Example:**
+```bash
+enapter3 blueprint profiles download --output profiles.zip
+```
+
+### blueprint profiles upload
+
+Upload blueprint profiles to the platform.
+
+**Usage:**
+```bash
+enapter3 blueprint profiles upload --path PATH
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--path, -p` | Profiles zip file path |
+
+**Example:**
+```bash
+enapter3 blueprint profiles upload --path ./profiles.zip
+```
+
+## Rule Engine Management
+
+Manage automation rules for your Enapter sites.
+
+### Common Options
+
+Rule engine commands support these options:
+
+| Option | Description |
+|--------|-------------|
+| `--connection, -c` | Name of the connection to use |
+| `--site-id` | Site ID |
+| `--api-allow-insecure` | Allow insecure connections |
+| `--verbose` | Log extra details |
+
+### rule-engine get
+
+Retrieve rule engine information.
+
+**Usage:**
+```bash
+enapter3 rule-engine get --site-id SITE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine get --site-id 12345
+```
+
+### rule-engine suspend
+
+Suspend execution of all rules on a site.
+
+**Usage:**
+```bash
+enapter3 rule-engine suspend --site-id SITE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine suspend --site-id 12345
+```
+
+### rule-engine resume
+
+Resume execution of rules on a site.
+
+**Usage:**
+```bash
+enapter3 rule-engine resume --site-id SITE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine resume --site-id 12345
+```
+
+### rule-engine rule create
+
+Create a new automation rule.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule create --site-id SITE_ID [options]
+```
+
+**Options:**
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `--slug` | Unique slug for the rule | Required |
+| `--script` | Path to script file | Required |
+| `--runtime-version` | Runtime version (V1, V3) | V3 |
+| `--exec-interval` | Execution interval (V1 only, e.g., 5s, 2m) | - |
+| `--disable` | Create rule in disabled state | false |
+
+**Example:**
+```bash
+enapter3 rule-engine rule create \
+ --site-id 12345 \
+ --slug my-automation-rule \
+ --script ./rule-script.lua \
+ --runtime-version V3
+```
+
+### rule-engine rule list
+
+List all rules for a site.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule list --site-id SITE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine rule list --site-id 12345
+```
+
+### rule-engine rule get
+
+Retrieve information about a specific rule.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule get --site-id SITE_ID --rule-id RULE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine rule get --site-id 12345 --rule-id my-rule
+```
+
+### rule-engine rule update
+
+Update rule metadata.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule update --site-id SITE_ID --rule-id RULE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--slug` | New slug for the rule |
+
+**Example:**
+```bash
+enapter3 rule-engine rule update \
+ --site-id 12345 \
+ --rule-id old-slug \
+ --slug new-slug
+```
+
+### rule-engine rule update-script
+
+Update the script of an existing rule.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule update-script --site-id SITE_ID --rule-id RULE_ID [options]
+```
+
+**Options:**
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `--script` | Path to new script file | Required |
+| `--runtime-version` | Runtime version (V1, V3) | V3 |
+| `--exec-interval` | Execution interval (V1 only) | - |
+
+**Example:**
+```bash
+enapter3 rule-engine rule update-script \
+ --site-id 12345 \
+ --rule-id my-rule \
+ --script ./electrolyser-controller.lua
+```
+
+### rule-engine rule delete
+
+Delete a rule.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule delete --site-id SITE_ID --rule-id RULE_ID
+```
+
+**Example:**
+```bash
+enapter3 rule-engine rule delete --site-id 12345 --rule-id my-rule
+```
+
+### rule-engine rule enable
+
+Enable one or more rules.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule enable --site-id SITE_ID --rule-id RULE_ID [--rule-id RULE_ID ...]
+```
+
+**Example:**
+```bash
+# Enable single rule
+enapter3 rule-engine rule enable --site-id 12345 --rule-id rule1
+
+# Enable multiple rules
+enapter3 rule-engine rule enable \
+ --site-id 12345 \
+ --rule-id rule1 \
+ --rule-id rule2 \
+ --rule-id rule3
+```
+
+### rule-engine rule disable
+
+Disable one or more rules.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule disable --site-id SITE_ID --rule-id RULE_ID [--rule-id RULE_ID ...]
+```
+
+**Example:**
+```bash
+# Disable single rule
+enapter3 rule-engine rule disable --site-id 12345 --rule-id rule1
+
+# Disable multiple rules
+enapter3 rule-engine rule disable \
+ --site-id 12345 \
+ --rule-id rule1 \
+ --rule-id rule2
+```
+
+### rule-engine rule logs
+
+Show logs for a specific rule.
+
+**Usage:**
+```bash
+enapter3 rule-engine rule logs --site-id SITE_ID --rule-id RULE_ID [options]
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--follow, -f` | Follow log output in real-time |
+
+**Example:**
+```bash
+# Stream rule logs in real-time
+enapter3 rule-engine rule logs --site-id 12345 --rule-id my-rule --follow
+```
+
+## Advanced Usage
+
+### Working with Multiple Connections
+
+You can manage multiple connections and switch between them:
+
+```bash
+# Add production connection
+enapter3 connection add \
+ --name production \
+ --token prod-token
+
+# Add staging connection
+enapter3 connection add \
+ --name staging \
+ --token staging-token
+
+# Use specific connection
+enapter3 device list --connection production
+enapter3 device list --connection staging
+
+# Set default
+enapter3 connection set-default --name production
+```
+
+### Gateway Connections
+
+Connect directly to an Enapter Gateway:
+
+```bash
+enapter3 connection add \
+ --name my-gateway \
+ --gateway \
+ --url https://gateway.local \
+ --token gateway-token \
+ --allow-insecure
+```
+
+### Site-Scoped Connections
+
+When working with Enapter Cloud, you can create site-scoped connections to avoid specifying `--site-id` for every command:
+
+```bash
+enapter3 connection add \
+ --name site-specific \
+ --token your-token \
+ --site-id 12345
+```
+
+When using this connection, the `--site-id` flag is automatically set for all commands:
+
+```bash
+# Without site-scoped connection (Cloud)
+enapter3 device list --site-id 12345
+
+# With site-scoped connection (Cloud)
+enapter3 device list --connection site-specific
+```
+
+### Cloud vs. Gateway Connections
+
+**Enapter Cloud connections** require `--site-id` for most device, rule-engine, and site-related commands:
+
+```bash
+# Cloud connection setup
+enapter3 connection add --name cloud --token YOUR_TOKEN
+
+# Commands require site-id
+enapter3 device list --site-id 12345
+enapter3 device get --site-id 12345 --device-id abc123
+enapter3 rule-engine get --site-id 12345
+
+# Or use site-scoped connection
+enapter3 connection add --name cloud-site --token YOUR_TOKEN --site-id 12345
+enapter3 device list --connection cloud-site # site-id is automatic
+```
+
+**Gateway connections** work in local-first mode and do not require `--site-id`:
+
+```bash
+# Gateway connection setup
+enapter3 connection add \
+ --name my-gateway \
+ --gateway \
+ --url https://gateway.local \
+ --token gateway-token
+
+# Commands work without site-id
+enapter3 device list
+enapter3 device get --device-id abc123
+enapter3 rule-engine get
+```
+
+### Expanding Data
+
+Many commands support the `--expand` flag to retrieve additional information:
+
+```bash
+# Get device with all available expansions
+enapter3 device get --device-id abc123 \
+ --expand connectivity \
+ --expand manifest \
+ --expand properties \
+ --expand communication \
+ --expand site
+
+# Get command execution with logs
+enapter3 device command get \
+ --device-id abc123 \
+ --execution-id exec-456 \
+ --expand log
+```
+
+### Streaming Data
+
+Commands that support real-time data streaming:
+
+```bash
+# Stream device logs
+enapter3 device logs --device-id abc123 --follow
+
+# Stream device telemetry
+enapter3 device telemetry --device-id abc123 --follow
+
+# Monitor device traffic
+enapter3 device monitor --device-id abc123
+
+# Stream rule logs
+enapter3 rule-engine rule logs --site-id 12345 --rule-id my-rule --follow
+```
+
+Use `Ctrl+C` to stop streaming.
+
+## Troubleshooting
+
+### Authentication Issues
+
+If you encounter authentication errors:
+
+1. Verify your token is correct:
+```bash
+echo $ENAPTER3_API_TOKEN
+```
+
+2. Check your connection configuration:
+```bash
+enapter3 connection list
+```
+
+3. Test with verbose logging:
+```bash
+enapter3 device list --verbose
+```
+
+### Insecure Connections
+
+For development or local Gateway connections, you may need to allow insecure connections:
+
+```bash
+# Global setting
+export ENAPTER3_API_ALLOW_INSECURE=true
+
+# Per-connection setting
+enapter3 connection add \
+ --name dev-gateway \
+ --gateway \
+ --url https://192.168.1.100 \
+ --token token \
+ --allow-insecure
+
+# Per-command setting
+enapter3 device list --api-allow-insecure
+```
+
+### Verbose Logging
+
+Enable verbose logging for debugging:
+
+```bash
+enapter3 device get --device-id abc123 --verbose
+```
+
+## Command Reference Summary
+
+### Connection Commands
+- `connection add` - Add a new connection
+- `connection list` - List all connections
+- `connection remove` - Remove a connection
+- `connection set-default` - Set default connection
+
+### Site Commands
+- `site list` - List user sites
+- `site get` - Get site details
+
+### Device Commands
+- `device create standalone` - Create standalone device
+- `device create lua-device` - Create Lua device
+- `device list` - List devices
+- `device get` - Get device details
+- `device update` - Update device
+- `device delete` - Delete device
+- `device change-blueprint` - Change device blueprint
+- `device logs` - Show device logs
+- `device telemetry` - Show device telemetry
+- `device monitor` - Monitor device traffic
+- `device run-terminal` - Open remote terminal
+- `device command execute` - Execute device command
+- `device command list` - List command executions
+- `device command get` - Get command execution details
+- `device communication-config generate` - Generate communication config
+
+### Blueprint Commands
+- `blueprint get` - Get blueprint metadata
+- `blueprint download` - Download blueprint
+- `blueprint upload` - Upload blueprint
+- `blueprint profiles download` - Download blueprint profiles
+- `blueprint profiles upload` - Upload blueprint profiles
+
+### Rule Engine Commands
+- `rule-engine get` - Get rule engine info
+- `rule-engine suspend` - Suspend rule execution
+- `rule-engine resume` - Resume rule execution
+- `rule-engine rule create` - Create new rule
+- `rule-engine rule list` - List rules
+- `rule-engine rule get` - Get rule details
+- `rule-engine rule update` - Update rule metadata
+- `rule-engine rule update-script` - Update rule script
+- `rule-engine rule delete` - Delete rule
+- `rule-engine rule enable` - Enable rule(s)
+- `rule-engine rule disable` - Disable rule(s)
+- `rule-engine rule logs` - Show rule logs
+
+## Best Practices
+
+### 1. Use Connections
+
+Set up named connections instead of relying solely on environment variables:
+
+```bash
+# Good
+enapter3 connection add --name prod --token TOKEN
+enapter3 device list --connection prod
+
+# Less flexible
+export ENAPTER3_API_TOKEN=TOKEN
+enapter3 device list
+```
+
+### 2. Use Site-Scoped Connections
+
+For multi-site setups, create separate connections for each site:
+
+```bash
+enapter3 connection add --name site-a --token TOKEN --site-id SITE_A_ID
+enapter3 connection add --name site-b --token TOKEN --site-id SITE_B_ID
+```
+
+### 3. Leverage Verbose Mode for Debugging
+
+When troubleshooting issues, always use verbose mode:
+
+```bash
+enapter3 device get --device-id abc123 --verbose
+```
+
+### 4. Use Expand Flags Wisely
+
+Only request expanded data when needed to minimize API load:
+
+```bash
+# Only expand what you need
+enapter3 device get --device-id abc123 --expand properties
+```
+
+### 5. Follow Logs for Real-Time Monitoring
+
+Use follow mode for development and debugging:
+
+```bash
+enapter3 device logs --device-id abc123 --follow --severity error
+```
+
+## Examples and Use Cases
+
+### Device Lifecycle Management
+
+**Cloud example:**
+
+```bash
+# Set site ID for all commands
+SITE_ID="12345"
+
+# 1. Create a new Lua device
+enapter3 device create lua-device \
+ --site-id "${SITE_ID}" \
+ --runtime-id ucm-001 \
+ --device-name "Temperature Sensor" \
+ --device-slug temp-sensor-01 \
+ --blueprint-path ./temp-sensor-blueprint/ # or .enbp file
+
+# 2. Monitor device startup
+enapter3 device logs --site-id "${SITE_ID}" --device-id temp-sensor-01 --follow
+
+# 3. Check device telemetry
+enapter3 device telemetry --site-id "${SITE_ID}" --device-id temp-sensor-01
+
+# 4. Update device if needed
+enapter3 device change-blueprint \
+ --site-id "${SITE_ID}" \
+ --device-id temp-sensor-01 \
+ --blueprint-path ./temp-sensor-v2/ # or .enbp file
+
+# 5. Execute commands on device
+enapter3 device command execute \
+ --site-id "${SITE_ID}" \
+ --device-id temp-sensor-01 \
+ --name calibrate \
+ --arguments '{"offset": 1.5}'
+```
+
+**Gateway example:**
+
+```bash
+# 1. Create a new Lua device (no site-id needed)
+enapter3 device create lua-device \
+ --runtime-id ucm-001 \
+ --device-name "Temperature Sensor" \
+ --device-slug temp-sensor-01 \
+ --blueprint-path ./temp-sensor-blueprint/ # or .enbp file
+
+# 2. Monitor device startup
+enapter3 device logs --device-id temp-sensor-01 --follow
+
+# 3. Check device telemetry
+enapter3 device telemetry --device-id temp-sensor-01
+
+# 4. Update device if needed
+enapter3 device change-blueprint \
+ --device-id temp-sensor-01 \
+ --blueprint-path ./temp-sensor-v2/ # or .enbp file
+
+# 5. Execute commands on device
+enapter3 device command execute \
+ --device-id temp-sensor-01 \
+ --name calibrate \
+ --arguments '{"offset": 1.5}'
+```
+
+### Blueprint Development Workflow
+
+```bash
+# 1. Download existing blueprint
+enapter3 blueprint download \
+ --blueprint-id existing-blueprint \
+ --output current-version.enbp
+
+# 2. Make modifications locally
+# ... edit files ...
+
+# 3. Upload updated blueprint
+enapter3 blueprint upload --path ./modified-blueprint/
+
+# 4. Update device to use new blueprint
+enapter3 device change-blueprint \
+ --device-id test-device \
+ --blueprint-path ./modified-blueprint/
+
+# 5. Test and monitor
+enapter3 device logs --device-id test-device --follow
+```
+
+### Rule Engine Automation
+
+```bash
+# 1. Create automation rule
+enapter3 rule-engine rule create \
+ --site-id 12345 \
+ --slug temperature-alert \
+ --script ./temperature-alert.lua \
+ --runtime-version V3
+
+# 2. Monitor rule execution
+enapter3 rule-engine rule logs \
+ --site-id 12345 \
+ --rule-id temperature-alert \
+ --follow
+
+# 3. Disable rule for maintenance
+enapter3 rule-engine rule disable \
+ --site-id 12345 \
+ --rule-id temperature-alert
+
+# 4. Update rule script
+enapter3 rule-engine rule update-script \
+ --site-id 12345 \
+ --rule-id temperature-alert \
+ --script ./temperature-alert-v2.lua
+
+# 5. Re-enable rule
+enapter3 rule-engine rule enable \
+ --site-id 12345 \
+ --rule-id temperature-alert
+```
+
+### Multi-Site Management
+
+```bash
+# Set up connections for each site
+enapter3 connection add --name site-factory --token TOKEN --site-id FACTORY_ID
+enapter3 connection add --name site-warehouse --token TOKEN --site-id WAREHOUSE_ID
+
+# List devices per site
+enapter3 device list --connection site-factory
+enapter3 device list --connection site-warehouse
+
+# Deploy same rule to multiple sites
+for site in site-factory site-warehouse; do
+ enapter3 rule-engine rule create \
+ --connection $site \
+ --slug monitoring-rule \
+ --script ./monitoring.lua
+done
+```
+
+## Getting Help
+
+### Command-Line Help
+
+Get help for any command using the `--help` flag:
+
+```bash
+# General help
+enapter3 --help
+
+# Command group help
+enapter3 device --help
+
+# Specific command help
+enapter3 device create lua-device --help
+```
+
+### Additional Resources
+
+- [Enapter Cloud Platform](https://cloud3.enapter.com)
+- [Enapter Documentation](https://developers.enapter.com)
+- [Blueprint Marketplace](https://marketplace.enapter.com)
+
+---
+
+## Appendix: Environment Variables
+
+For unattended setups, CI/CD pipelines, and shell scripting, you can use environment variables instead of managing connections. This approach is recommended for automation scenarios where interactive connection management is not practical.
+
+### Supported Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `ENAPTER3_API_TOKEN` | Enapter API access token | - |
+| `ENAPTER3_API_URL` | Enapter API base URL | `https://api.enapter.com` |
+| `ENAPTER3_API_ALLOW_INSECURE` | Allow insecure connections | `false` |
+
+### Usage in Scripts
+
+**Gateway (local-first) script example:**
+
+```bash
+#!/bin/bash
+
+# Set authentication for Gateway
+export ENAPTER3_API_TOKEN="your-gateway-token"
+export ENAPTER3_API_URL="http://enapter-gateway.local/api"
+export ENAPTER3_API_ALLOW_INSECURE=true
+
+# Run commands - no site-id needed for Gateway
+enapter3 device list
+enapter3 device get --device-id abc123
+enapter3 rule-engine get
+```
+
+**Cloud script example:**
+
+```bash
+#!/bin/bash
+
+# Set authentication for Cloud
+export ENAPTER3_API_TOKEN="your-cloud-token"
+export ENAPTER3_API_URL="https://api.enapter.com"
+
+# Site ID required for Cloud commands
+SITE_ID="12345"
+
+enapter3 device list --site-id "${SITE_ID}"
+enapter3 device get --site-id "${SITE_ID}" --device-id abc123
+enapter3 rule-engine get --site-id "${SITE_ID}"
+```
+
+**CI/CD pipeline example (Cloud):**
+
+```bash
+#!/bin/bash
+
+# Use secrets from CI/CD environment
+export ENAPTER3_API_TOKEN="${ENAPTER_TOKEN}"
+export ENAPTER3_API_URL="https://api.enapter.com"
+
+# Site ID is required for Cloud deployments
+SITE_ID="${ENAPTER_SITE_ID}"
+
+# Deploy blueprint
+enapter3 blueprint upload --path ./my-blueprint.enbp
+
+# Create device (site-id required for Cloud)
+enapter3 device create lua-device \
+ --site-id "${SITE_ID}" \
+ --runtime-id "${RUNTIME_ID}" \
+ --device-name "Automated Device" \
+ --device-slug "auto-device-${CI_BUILD_ID}" \
+ --blueprint-id "my-blueprint"
+```
+
+**CI/CD pipeline example (Gateway):**
+
+```bash
+#!/bin/bash
+
+# Use secrets from CI/CD environment for Gateway
+export ENAPTER3_API_TOKEN="${GATEWAY_TOKEN}"
+export ENAPTER3_API_URL="http://${GATEWAY_ADDRESS}/api"
+export ENAPTER3_API_ALLOW_INSECURE=true
+
+# Deploy blueprint
+enapter3 blueprint upload --path ./my-blueprint.enbp
+
+# Create device (no site-id needed for Gateway)
+enapter3 device create lua-device \
+ --runtime-id "${RUNTIME_ID}" \
+ --device-name "Automated Device" \
+ --device-slug "auto-device-${CI_BUILD_ID}" \
+ --blueprint-id "my-blueprint"
+```
+
+### Precedence Rules
+
+When both environment variables and connection configuration are present:
+
+1. Command-line flags (e.g., `--connection`) have highest priority
+2. Connection configuration (from `connection add`) is used if no command-line flags or environment variables specified
+3. Environment variables are used only if no connection is configured or specified
+
+---
+
+*This documentation is for Enapter CLI version 3.x*
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..0703d40 100644
--- a/internal/app/enaptercli/cmd_base.go
+++ b/internal/app/enaptercli/cmd_base.go
@@ -1,66 +1,443 @@
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
+ colorize bool
+ writer io.Writer
+ errWriter io.Writer
+ userAgent string
+ 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.writer = cliCtx.App.Writer
+ c.errWriter = cliCtx.App.ErrWriter
+ c.colorize = colorsSupported(c.writer)
+
+ c.userAgent = "enapter-cli/" + cliCtx.App.Version
+ 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..ed423b5
--- /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..8130754
--- /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..7ba0bca
--- /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..1578e2f
--- /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..a3ceecd
--- /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..d9916a8
--- /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..4fe1c27
--- /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..3ef5836
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device.go
@@ -0,0 +1,95 @@
+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(),
+ buildCmdDeviceStream(),
+ 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..41486a6
--- /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..2b57e19
--- /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..9c9c47c
--- /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..271173b
--- /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: "Comma-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..c076aed
--- /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..5dcf3b2
--- /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..1dc8853
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_lua_device.go
@@ -0,0 +1,147 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "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": strings.TrimSpace(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..2418c55
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_standalone.go
@@ -0,0 +1,75 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "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": strings.TrimSpace(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..fa68ccf
--- /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..a890e0c
--- /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: "Comma-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..c2e3e69
--- /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: "Comma-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..88edd91
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_logs.go
@@ -0,0 +1,233 @@
+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"`
+ Log struct {
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ } `json:"log"`
+ }
+ if err := json.NewDecoder(r).Decode(&msg); err != nil {
+ return fmt.Errorf("parse payload: %w", err)
+ }
+
+ color := c.logColor(msg.Log.Severity)
+ if color != "" {
+ fmt.Fprint(c.writer, color)
+ }
+ fmt.Fprintf(c.writer, "%s [%s] %s\n", msg.ReceivedAt, msg.Log.Severity, msg.Log.Message)
+ if color != "" {
+ fmt.Fprint(c.writer, colorReset)
+ }
+ 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 {
+ color := c.logColor(l.Severity)
+ if color != "" {
+ fmt.Fprint(c.writer, color)
+ }
+ fmt.Fprintf(c.writer, "%s [%s] %s\n", l.ReceivedAt, l.Severity, l.Message)
+ if color != "" {
+ fmt.Fprint(c.writer, colorReset)
+ }
+ }
+ return nil
+ }),
+ })
+}
+
+func (c *cmdDeviceLogs) logColor(severity string) string {
+ if !c.colorize {
+ return ""
+ }
+ switch severity {
+ case "warning":
+ return colorYellow
+ case "error":
+ return colorRed
+ default:
+ return ""
+ }
+}
diff --git a/internal/app/enaptercli/cmd_device_monitor.go b/internal/app/enaptercli/cmd_device_monitor.go
new file mode 100644
index 0000000..347745f
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_monitor.go
@@ -0,0 +1,191 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "time"
+
+ "github.com/urfave/cli/v2"
+)
+
+type cmdDeviceMonitor struct {
+ cmdDevice
+ deviceID string
+ includeRuntime bool
+}
+
+func buildCmdDeviceStream() *cli.Command {
+ cmd := &cmdDeviceMonitor{}
+ return &cli.Command{
+ Name: "monitor",
+ Usage: "Monitor device traffic",
+ CustomHelpTemplate: cmd.CommandHelpTemplate(),
+ Flags: cmd.Flags(),
+ Before: cmd.Before,
+ Action: func(cliCtx *cli.Context) error {
+ return cmd.do(cliCtx.Context)
+ },
+ }
+}
+
+func (c *cmdDeviceMonitor) 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: "include-runtime",
+ Usage: "Monitor device's runtime traffic too",
+ Destination: &c.includeRuntime,
+ })
+}
+
+func (c *cmdDeviceMonitor) do(ctx context.Context) error {
+ deviceID, runtimeID, err := c.resolveDeviceIDs(ctx)
+ if err != nil {
+ return err
+ }
+
+ query := make(url.Values)
+ query.Add("id.in", deviceID)
+ if runtimeID != "" {
+ query.Add("id.in", runtimeID)
+ }
+
+ return c.runWebSocket(ctx, runWebSocketParams{
+ Path: "/",
+ Query: query,
+ RespProcessor: func(r io.Reader) error {
+ return c.process(r, deviceID, runtimeID)
+ },
+ })
+}
+
+func (c *cmdDeviceMonitor) resolveDeviceIDs(ctx context.Context) (string, string, error) {
+ var resp struct {
+ Device struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Communication struct {
+ Type string `json:"type"`
+ UpstreamID string `json:"upstream_id"`
+ } `json:"communication"`
+ } `json:"device"`
+ }
+
+ var query url.Values
+ if c.includeRuntime {
+ query = url.Values{"expand": {"communication"}}
+ }
+
+ if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: c.deviceID,
+ Query: query,
+ 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
+ }
+
+ if !c.includeRuntime {
+ return resp.Device.ID, "", nil
+ }
+
+ runtimeCommTypes := []string{"UCM_LUA"}
+ if !slices.Contains(runtimeCommTypes, resp.Device.Communication.Type) {
+ fmt.Fprintln(c.errWriter,
+ "WARNING: device does not run on a runtime, --include-runtime is ignored")
+ return resp.Device.ID, "", nil
+ }
+
+ return resp.Device.ID, resp.Device.Communication.UpstreamID, nil
+}
+
+type streamMessage struct {
+ DeviceID string `json:"device_id"`
+ ReceivedAt time.Time `json:"received_at"`
+ Timestamp int64 `json:"timestamp"`
+ Telemetry json.RawMessage `json:"telemetry,omitempty"`
+ Properties json.RawMessage `json:"properties,omitempty"`
+ Log *struct {
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ } `json:"log,omitempty"`
+}
+
+func (c *cmdDeviceMonitor) process(r io.Reader, deviceID, runtimeID string) error {
+ var m streamMessage
+ if err := json.NewDecoder(r).Decode(&m); err != nil {
+ return fmt.Errorf("parse payload: %w", err)
+ }
+
+ color := c.messageColor(m)
+ if color != "" {
+ fmt.Fprint(c.writer, color)
+ }
+
+ fmt.Fprint(c.writer, c.messageTimestamp(m))
+ fmt.Fprint(c.writer, " ")
+
+ switch m.DeviceID {
+ case runtimeID:
+ fmt.Fprint(c.writer, "runtime ")
+ case deviceID:
+ fmt.Fprint(c.writer, "device ")
+ }
+
+ switch {
+ case m.Telemetry != nil:
+ fmt.Fprint(c.writer, "telemetry: ")
+ fmt.Fprint(c.writer, string(m.Telemetry))
+ case m.Properties != nil:
+ fmt.Fprint(c.writer, "properties: ")
+ fmt.Fprint(c.writer, string(m.Properties))
+ case m.Log != nil:
+ fmt.Fprint(c.writer, "logs: ")
+ fmt.Fprintf(c.writer, "[%s] %s", m.Log.Severity, m.Log.Message)
+ }
+
+ if color != "" {
+ fmt.Fprint(c.writer, colorReset)
+ }
+ fmt.Fprintln(c.writer)
+
+ return nil
+}
+
+func (c *cmdDeviceMonitor) messageColor(m streamMessage) string {
+ if !c.colorize {
+ return ""
+ }
+ if m.Log != nil {
+ switch m.Log.Severity {
+ case "warning":
+ return colorYellow
+ case "error":
+ return colorRed
+ }
+ }
+ return ""
+}
+
+func (c *cmdDeviceMonitor) messageTimestamp(m streamMessage) string {
+ ts := m.ReceivedAt
+ if ts.IsZero() {
+ ts = time.Unix(m.Timestamp, 0)
+ }
+ return ts.UTC().Format(time.RFC3339)
+}
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..ce87948
--- /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..9a3636c
--- /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..688a4e1
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_update.go
@@ -0,0 +1,74 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "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": strings.TrimSpace(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..9751f13
--- /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..168b786
--- /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..f134f89
--- /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/colors.go b/internal/app/enaptercli/colors.go
new file mode 100644
index 0000000..b3b11d6
--- /dev/null
+++ b/internal/app/enaptercli/colors.go
@@ -0,0 +1,28 @@
+package enaptercli
+
+import (
+ "io"
+ "os"
+
+ "golang.org/x/term"
+)
+
+const (
+ colorRed = "\033[31m"
+ colorYellow = "\033[33m"
+ colorReset = "\033[0m"
+)
+
+func colorsSupported(w io.Writer) bool {
+ f, ok := w.(*os.File)
+ if !ok {
+ return false
+ }
+ if !term.IsTerminal(int(f.Fd())) {
+ return false
+ }
+ if _, ok := os.LookupEnv("NO_COLOR"); ok {
+ return false
+ }
+ return true
+}
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..47b0f6d 100644
--- a/internal/app/enaptercli/errors.go
+++ b/internal/app/enaptercli/errors.go
@@ -3,7 +3,10 @@ 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, " +
+ "specify --site-id or select a connection with a configured site ID")
+ 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..9b3a46c
--- /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..2e8823d
--- /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..319dce9
--- /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..6e811b9
--- /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..a514e89
--- /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..3a2d618
--- /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..37f218c
--- /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..255a0c0
--- /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..57b1f8f
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device
@@ -0,0 +1,29 @@
+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
+ monitor Monitor device traffic
+ 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..ec49a9c
--- /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..1f582a7
--- /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..916c0e5
--- /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 ] Comma-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..0c2fbb2
--- /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..0ed4da6
--- /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..a1bec04
--- /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..c9f6bd5
--- /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..cf1396b
--- /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..fb28724
--- /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 ] Comma-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..1442c73
--- /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 ] Comma-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..d032803
--- /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 monitor b/internal/app/enaptercli/testdata/helps/enapter device monitor
new file mode 100644
index 0000000..40e04b1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device monitor
@@ -0,0 +1,20 @@
+NAME:
+ enaptercli.test device monitor - Monitor device traffic
+
+USAGE:
+ enaptercli.test device monitor [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
+ --include-runtime Monitor device's runtime traffic too (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 run-terminal b/internal/app/enaptercli/testdata/helps/enapter device run-terminal
new file mode 100644
index 0000000..686baae
--- /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..fd1031e
--- /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..30c43de
--- /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..236b2aa
--- /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..262298e
--- /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..719e260
--- /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..8dc3ff5
--- /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..7db919d
--- /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..e4140d3
--- /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..597aa2f
--- /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..507b83b
--- /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..8ce395b
--- /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..27f7166
--- /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..ee117de
--- /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..d3b462d
--- /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..2034d11
--- /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..00b3780
--- /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..4acb773
--- /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, specify --site-id or select a connection with a configured site ID
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..4acb773
--- /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, specify --site-id or select a connection with a configured site ID
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..4acb773
--- /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, specify --site-id or select a connection with a configured site ID
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
-}