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/blueprint/zip.go b/blueprint/zip.go
new file mode 100644
index 0000000..661e699
--- /dev/null
+++ b/blueprint/zip.go
@@ -0,0 +1,121 @@
+// Package blueprint provides functions for packaging Enapter blueprints
+// into zip archives, with support for .blueprintignore files.
+package blueprint
+
+import (
+ "archive/zip"
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "strings"
+
+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
+)
+
+const ignoreFileName = ".blueprintignore"
+
+// Zip creates a zip archive from the blueprint directory at the given
+// filesystem root. It respects .blueprintignore patterns (gitignore syntax).
+// The .blueprintignore file itself is excluded from the archive.
+func Zip(fsys fs.FS) ([]byte, error) {
+ matcher, err := loadIgnoreMatcher(fsys)
+ if err != nil {
+ return nil, fmt.Errorf("load %s: %w", ignoreFileName, err)
+ }
+
+ buf := &bytes.Buffer{}
+ zw := zip.NewWriter(buf)
+
+ err = fs.WalkDir(fsys, ".", func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if path == "." {
+ return nil
+ }
+
+ if matcher.Match(path, entry.IsDir()) {
+ if entry.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+
+ if entry.IsDir() {
+ return nil
+ }
+
+ f, err := fsys.Open(path)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ defer f.Close()
+
+ zf, err := zw.Create(path)
+ if err != nil {
+ return fmt.Errorf("create: %w", err)
+ }
+
+ if _, err = io.Copy(zf, f); err != nil {
+ return fmt.Errorf("copy: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("walk dir: %w", err)
+ }
+
+ if err := zw.Close(); err != nil {
+ return nil, fmt.Errorf("close zip: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+type ignoreMatcher struct {
+ m gitignore.Matcher
+ noFile bool
+}
+
+func (m *ignoreMatcher) Match(path string, isDir bool) bool {
+ if m.noFile {
+ return false
+ }
+
+ if path == ignoreFileName {
+ return true
+ }
+
+ parts := strings.Split(path, "/")
+ return m.m.Match(parts, isDir)
+}
+
+func loadIgnoreMatcher(fsys fs.FS) (*ignoreMatcher, error) {
+ f, err := fsys.Open(ignoreFileName)
+ if errors.Is(err, fs.ErrNotExist) {
+ return &ignoreMatcher{noFile: true}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var patterns []gitignore.Pattern
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ patterns = append(patterns, gitignore.ParsePattern(line, nil))
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return &ignoreMatcher{m: gitignore.NewMatcher(patterns)}, nil
+}
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/cmd/enapter/main.go b/cmd/enapter/main.go
index 43bc5bd..4b36785 100644
--- a/cmd/enapter/main.go
+++ b/cmd/enapter/main.go
@@ -7,9 +7,9 @@ import (
"os/signal"
"runtime"
- "github.com/urfave/cli/v2"
+ "github.com/urfave/cli/v3"
- "github.com/enapter/enapter-cli/internal/app/enaptercli"
+ "github.com/enapter/enapter-cli/v3/internal/app/enaptercli"
)
//nolint:gochecknoglobals // because sets up via ldflags
@@ -20,13 +20,13 @@ var (
)
func main() {
- cli.VersionPrinter = func(c *cli.Context) {
+ cli.VersionPrinter = func(cmd *cli.Command) {
fmt.Printf("Enapter CLI %s, commit %s, built at %s, Go version %s\n",
- c.App.Version, commit, date, runtime.Version())
+ cmd.Root().Version, commit, date, runtime.Version())
}
- app := enaptercli.NewApp()
- app.Version = version
+ cmd := enaptercli.NewApp()
+ cmd.Version = version
interruptCh := make(chan os.Signal, 1)
signal.Notify(interruptCh, os.Interrupt)
@@ -39,7 +39,7 @@ func main() {
os.Exit(1)
}()
- if err := app.RunContext(ctx, os.Args); err != nil {
+ if err := cmd.Run(ctx, os.Args); err != nil {
fmt.Println("")
fmt.Println("Error:", err)
os.Exit(1)
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..59e04e2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,21 +1,23 @@
-module github.com/enapter/enapter-cli
+module github.com/enapter/enapter-cli/v3
-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/go-git/go-git/v5 v5.17.2
+ github.com/gorilla/websocket v1.5.3
+ github.com/stretchr/testify v1.11.1
+ github.com/urfave/cli/v3 v3.7.0
+ 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/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.8.0 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // 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
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index abef50e..c55b3e1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,30 +1,39 @@
-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/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/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
+github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
+github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
+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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
+github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+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/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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
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..61a87e7
--- /dev/null
+++ b/internal/app/cliflags/duration.go
@@ -0,0 +1,27 @@
+package cliflags
+
+import (
+ "github.com/urfave/cli/v3"
+)
+
+// 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/cliflags/trim.go b/internal/app/cliflags/trim.go
new file mode 100644
index 0000000..151edd0
--- /dev/null
+++ b/internal/app/cliflags/trim.go
@@ -0,0 +1,17 @@
+package cliflags
+
+import (
+ "context"
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+// TrimSpaceAction returns a cli.StringFlag Action that trims leading and
+// trailing whitespace from the flag value and stores it in dest.
+func TrimSpaceAction(dest *string) func(context.Context, *cli.Command, string) error {
+ return func(_ context.Context, _ *cli.Command, v string) error {
+ *dest = strings.TrimSpace(v)
+ return nil
+ }
+}
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..1c946eb 100644
--- a/internal/app/enaptercli/app_test.go
+++ b/internal/app/enaptercli/app_test.go
@@ -9,41 +9,38 @@ import (
"sync"
"time"
- "github.com/urfave/cli/v2"
+ "github.com/urfave/cli/v3"
- "github.com/enapter/enapter-cli/internal/app/enaptercli"
+ "github.com/enapter/enapter-cli/v3/internal/app/enaptercli"
)
var errExitTimeout = errors.New("exit timed out")
type testApp struct {
- app *cli.App
+ app *cli.Command
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.ExitErrHandler = func(*cli.Context, error) {}
+ app.ErrWriter = outBuf
+ app.ExitErrHandler = func(context.Context, *cli.Command, error) {}
errCh := make(chan error, 1)
ctx, cancel := context.WithCancel(context.Background())
go func() {
- errCh <- app.RunContext(ctx, args)
+ errCh <- app.Run(ctx, args)
}()
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..bae005e 100644
--- a/internal/app/enaptercli/cmd_base.go
+++ b/internal/app/enaptercli/cmd_base.go
@@ -1,66 +1,445 @@
package enaptercli
import (
+ "bytes"
+ "cmp"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
"io"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
- "github.com/urfave/cli/v2"
+ "github.com/gorilla/websocket"
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/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"},
+ Sources: cli.EnvVars("ENAPTER3_CONNECTION"),
+ Destination: &c.connName,
+ },
&cli.StringFlag{
Name: "token",
Usage: "Enapter API token",
- EnvVars: []string{"ENAPTER_API_TOKEN"},
+ Sources: cli.EnvVars("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",
+ Sources: cli.EnvVars("ENAPTER3_API_URL"),
+ Value: defaultURL,
Hidden: true,
- Value: "https://api.enapter.com",
- Destination: &c.apiHost,
+ Destination: &c.apiURL,
+ Action: func(_ context.Context, _ *cli.Command, 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",
+ Sources: cli.EnvVars("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
+func (c *cmdBase) Before(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ if err := c.setupCredentials(cmd); err != nil {
+ return ctx, err
}
- c.writer = cliCtx.App.Writer
- return nil
+
+ c.writer = cmd.Root().Writer
+ c.errWriter = cmd.Root().ErrWriter
+ c.colorize = colorsSupported(c.writer)
+
+ c.userAgent = "enapter-cli/" + cmd.Root().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 ctx, nil
}
-func (c *cmdBase) HelpTemplate() string {
- return cli.CommandHelpTemplate + `ENVIRONMENT VARIABLES:
- ENAPTER_API_TOKEN Enapter API access token
+func (c *cmdBase) setupCredentials(cmd *cli.Command) 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 cmd.IsSet("token") || cmd.IsSet("api-url") || cmd.IsSet("api-allow-insecure") {
+ fmt.Fprintln(cmd.Root().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)
+ ENAPTER3_CONNECTION Name of the connection to use
`
+
+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(cmd *cli.Command, supportedFields []string) error {
+ for _, field := range cmd.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..b7ad585
--- /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/v3"
+)
+
+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..7119dd4
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint.go
@@ -0,0 +1,59 @@
+package enaptercli
+
+import (
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+type cmdBlueprint struct {
+ cmdBase
+}
+
+func buildCmdBlueprint() *cli.Command {
+ cmd := &cmdBlueprint{}
+ return &cli.Command{
+ Name: "blueprint",
+ Usage: "Manage blueprints",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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..3092170
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..f1b3236
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_get.go
@@ -0,0 +1,53 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.get(ctx)
+ },
+ }
+}
+
+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..0fc1300
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_profiles.go
@@ -0,0 +1,22 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v3"
+)
+
+type cmdBlueprintProfiles struct {
+ cmdBase
+}
+
+func buildCmdBlueprintProfiles() *cli.Command {
+ cmd := &cmdBlueprintProfiles{}
+ return &cli.Command{
+ Name: "profiles",
+ Usage: "Manage blueprint profiles",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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..6a8ec09
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..13973a2
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.upload(ctx)
+ },
+ }
+}
+
+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..bcd1d32
--- /dev/null
+++ b/internal/app/enaptercli/cmd_blueprint_upload.go
@@ -0,0 +1,103 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/blueprint"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.upload(ctx)
+ },
+ }
+}
+
+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 = blueprint.Zip(os.DirFS(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..494aec1
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection.go
@@ -0,0 +1,18 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v3"
+)
+
+func buildCmdConnection() *cli.Command {
+ return &cli.Command{
+ Name: "connection",
+ Usage: "Manage connections to Enapter Cloud and Gateways",
+ Commands: []*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..ea4ba12
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_add.go
@@ -0,0 +1,157 @@
+package enaptercli
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/cliflags"
+ "github.com/enapter/enapter-cli/v3/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,
+ Action: cliflags.TrimSpaceAction(&cmd.name),
+ },
+ &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(ctx context.Context, cliCmd *cli.Command) 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(ctx, cliCmd)
+ 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(ctx context.Context, cliCmd *cli.Command) (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(
+ ctx, 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/"+cliCmd.Root().Version)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ if e := (&tls.CertificateVerificationError{}); errors.As(err, &e) {
+ return "", fmt.Errorf("resolve site: %w (try to use --allow-insecure)", err)
+ }
+ 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..309cc9f
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_list.go
@@ -0,0 +1,63 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "slices"
+ "text/tabwriter"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/configfile"
+)
+
+type cmdConnectionList struct{}
+
+func buildCmdConnectionList() *cli.Command {
+ cmd := &cmdConnectionList{}
+ return &cli.Command{
+ Name: "list",
+ Usage: "List all connections",
+ Action: func(_ context.Context, cliCmd *cli.Command) error {
+ return cmd.do(cliCmd)
+ },
+ }
+}
+
+func (c *cmdConnectionList) do(cliCmd *cli.Command) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ const padding = 3
+ w := tabwriter.NewWriter(cliCmd.Root().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..5e8c301
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_remove.go
@@ -0,0 +1,53 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/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: func(_ context.Context, cliCmd *cli.Command) error {
+ return cmd.do(cliCmd)
+ },
+ }
+}
+
+func (c *cmdConnectionRemove) do(cliCmd *cli.Command) error {
+ config, err := configfile.Load()
+ if err != nil {
+ return err
+ }
+
+ if _, ok := config.Connections[c.name]; !ok {
+ fmt.Fprintln(cliCmd.Root().ErrWriter, "WARNING: unknown connection.")
+ return nil
+ }
+
+ delete(config.Connections, c.name)
+ if config.DefaultConn == c.name {
+ fmt.Fprintln(cliCmd.Root().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..b84bfbb
--- /dev/null
+++ b/internal/app/enaptercli/cmd_connection_set_default.go
@@ -0,0 +1,44 @@
+package enaptercli
+
+import (
+ "context"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/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(context.Context, *cli.Command) 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..c90142a
--- /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/v3"
+)
+
+type cmdDevice struct {
+ cmdBase
+ siteID string
+}
+
+func buildCmdDevice() *cli.Command {
+ cmd := &cmdDevice{}
+ return &cli.Command{
+ Name: "device",
+ Usage: "Manage devices",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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(cmd *cli.Command) error {
+ return validateExpandFlag(cmd, 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..e435431
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_change_blueprint.go
@@ -0,0 +1,89 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ ctx, err := c.cmdDevice.Before(ctx, cmd)
+ if err != nil {
+ return ctx, err
+ }
+ if c.blueprintID != "" && c.blueprintPath != "" {
+ return ctx, errOnlyOneBlueprinFlag
+ }
+ if c.blueprintID == "" && c.blueprintPath == "" {
+ return ctx, errMissedBlueprintFlag
+ }
+ return ctx, c.validateExpandFlag(cmd)
+}
+
+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..d8f4be6
--- /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/v3"
+)
+
+type cmdDeviceCommand struct {
+ cmdDevice
+ deviceID string
+}
+
+func buildCmdDeviceCommand() *cli.Command {
+ cmd := &cmdDeviceCommand{}
+ return &cli.Command{
+ Name: "command",
+ Usage: "Manage device commands",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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..dbd2e23
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..a063c84
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_command_get.go
@@ -0,0 +1,66 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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.StringSliceFlag{
+ Name: "expand",
+ Usage: "Comma-separated list of expanded options (supported values: log)",
+ Destination: &c.expand,
+ },
+ )
+}
+
+func (c *cmdDeviceCommandGet) Before(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ ctx, err := c.cmdDevice.Before(ctx, cmd)
+ if err != nil {
+ return ctx, err
+ }
+ return ctx, validateExpandFlag(cmd, []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..f33fae9
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..65d0ca2
--- /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/v3"
+)
+
+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(),
+ Commands: []*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..871dab2
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..5b790c0
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create.go
@@ -0,0 +1,22 @@
+package enaptercli
+
+import (
+ "github.com/urfave/cli/v3"
+)
+
+type cmdDeviceCreate struct {
+ cmdBase
+}
+
+func buildCmdDeviceCreate() *cli.Command {
+ cmd := &cmdDeviceCreate{}
+ return &cli.Command{
+ Name: "create",
+ Usage: "Create devices of different types",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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..0bd163d
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_lua_device.go
@@ -0,0 +1,151 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/cliflags"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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,
+ Action: cliflags.TrimSpaceAction(&c.deviceName),
+ }, &cli.StringFlag{
+ Name: "device-slug",
+ Usage: "slug for the new Lua device",
+ Destination: &c.deviceSlug,
+ Action: cliflags.TrimSpaceAction(&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(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ ctx, err := c.cmdDeviceCreate.Before(ctx, cmd)
+ if err != nil {
+ return ctx, err
+ }
+ if c.blueprintID != "" && c.blueprintPath != "" {
+ return ctx, errOnlyOneBlueprinFlag
+ }
+ if c.blueprintID == "" && c.blueprintPath == "" {
+ return ctx, errMissedBlueprintFlag
+ }
+ return ctx, nil
+}
+
+func (c *cmdDeviceCreateLua) do(ctx context.Context) error {
+ if c.blueprintPath != "" {
+ blueprintID, err := uploadBlueprintAndReturnBlueprintID(ctx, c.blueprintPath, c.doHTTPRequest)
+ if err != nil {
+ return fmt.Errorf("upload blueprint: %w", err)
+ }
+ c.blueprintID = blueprintID
+ }
+
+ // Cloud API does not allow slugs as runtime ID for now
+ runtimeID, err := c.resolveRuntimeID(ctx)
+ if err != nil {
+ return fmt.Errorf("resolve runtime ID: %w", err)
+ }
+
+ body, err := json.Marshal(map[string]interface{}{
+ "runtime_id": runtimeID,
+ "name": c.deviceName,
+ "slug": c.deviceSlug,
+ "blueprint_id": c.blueprintID,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/provisioning/lua_device",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
+
+func (c *cmdDeviceCreateLua) resolveRuntimeID(ctx context.Context) (string, error) {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ if errors.Is(err, errSiteIDMissing) {
+ return c.runtimeID, nil
+ }
+ return "", err
+ }
+
+ var resp struct {
+ Device struct {
+ ID string `json:"id"`
+ } `json:"device"`
+ }
+
+ if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodGet,
+ Path: "/sites/" + siteID + "/devices/" + c.runtimeID,
+ RespProcessor: func(r *http.Response) error {
+ if r.StatusCode != http.StatusOK {
+ return cli.Exit(parseRespErrorMessage(r), 1)
+ }
+ return json.NewDecoder(r.Body).Decode(&resp)
+ },
+ }); err != nil {
+ return "", err
+ }
+
+ return resp.Device.ID, nil
+}
diff --git a/internal/app/enaptercli/cmd_device_create_standalone.go b/internal/app/enaptercli/cmd_device_create_standalone.go
new file mode 100644
index 0000000..b52e45d
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_create_standalone.go
@@ -0,0 +1,78 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/cliflags"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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,
+ Action: cliflags.TrimSpaceAction(&c.deviceName),
+ }, &cli.StringFlag{
+ Name: "device-slug",
+ Usage: "Slug for the new standalone device",
+ Destination: &c.deviceSlug,
+ Action: cliflags.TrimSpaceAction(&c.deviceSlug),
+ })
+}
+
+func (c *cmdDeviceCreateStandalone) do(ctx context.Context) error {
+ siteID, err := c.chooseSiteID(c.siteID)
+ if err != nil {
+ return err
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "site_id": siteID,
+ "name": c.deviceName,
+ "slug": c.deviceSlug,
+ })
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPost,
+ Path: "/provisioning/standalone",
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_device_delete.go b/internal/app/enaptercli/cmd_device_delete.go
new file mode 100644
index 0000000..a2ab7e7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_delete.go
@@ -0,0 +1,47 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..fda3295
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_get.go
@@ -0,0 +1,66 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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.StringSliceFlag{
+ Name: "expand",
+ Usage: "Comma-separated list of expanded device information (supported values: " +
+ strings.Join(c.supportedExpandFields(), ", ") + ")",
+ Destination: &c.expand,
+ })
+}
+
+func (c *cmdDeviceGet) Before(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ ctx, err := c.cmdDevice.Before(ctx, cmd)
+ if err != nil {
+ return ctx, err
+ }
+ return ctx, c.validateExpandFlag(cmd)
+}
+
+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..ee2c221
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_list.go
@@ -0,0 +1,73 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+func (c *cmdDeviceList) Flags() []cli.Flag {
+ flags := c.cmdDevice.Flags()
+ return append(flags, &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(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+ ctx, err := c.cmdDevice.Before(ctx, cmd)
+ if err != nil {
+ return ctx, err
+ }
+ return ctx, c.validateExpandFlag(cmd)
+}
+
+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..e9d3fa3
--- /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/v3"
+)
+
+type cmdDeviceLogs struct {
+ cmdDevice
+ deviceID string
+ follow bool
+ receivedAtGte time.Time
+ receivedAtLt time.Time
+ offset int
+ limit int
+ severity string
+ order string
+ retention 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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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: "received-at-gte",
+ Usage: "From timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)",
+ Destination: &c.receivedAtGte,
+ Config: cli.TimestampConfig{Layouts: []string{time.RFC3339}},
+ }, &cli.TimestampFlag{
+ Name: "received-at-lt",
+ Usage: "To timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)",
+ Destination: &c.receivedAtLt,
+ Config: cli.TimestampConfig{Layouts: []string{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(_ context.Context, _ *cli.Command, 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: "retention",
+ Usage: "Filter logs by retention (ALL[default], PERSISTENT, EPHEMERAL)",
+ Destination: &c.retention,
+ Action: func(_ context.Context, _ *cli.Command, v string) error {
+ if v != "ALL" && v != "PERSISTENT" && v != "EPHEMERAL" {
+ return fmt.Errorf("%w: should be one of [ALL, PERSISTENT, EPHEMERAL]", 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.receivedAtGte.IsZero() {
+ return cli.Exit("Option --received-at-gte is unsupported in follow mode.", 1)
+ }
+ if !c.receivedAtLt.IsZero() {
+ return cli.Exit("Option --received-at-lt 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.retention != "" {
+ query.Add("retention", c.retention)
+ }
+
+ 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.receivedAtGte.IsZero() {
+ query.Add("received_at.gte", c.receivedAtGte.Format(time.RFC3339))
+ }
+ if !c.receivedAtLt.IsZero() {
+ query.Add("received_at.lt", c.receivedAtLt.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.retention != "" {
+ query.Add("retention", c.retention)
+ }
+
+ 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..9e8667d
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..b45f009
--- /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/v3"
+ "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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..5e36840
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..418d2c7
--- /dev/null
+++ b/internal/app/enaptercli/cmd_device_update.go
@@ -0,0 +1,77 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/cliflags"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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,
+ Action: cliflags.TrimSpaceAction(&c.name),
+ },
+ &cli.StringFlag{
+ Name: "slug",
+ Usage: "Device slug",
+ Destination: &c.slug,
+ Action: cliflags.TrimSpaceAction(&c.slug),
+ },
+ )
+}
+
+func (c *cmdDeviceUpdate) do(ctx context.Context) error {
+ payload := map[string]string{
+ "name": c.name,
+ "slug": c.slug,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+
+ return c.doHTTPRequest(ctx, doHTTPRequestParams{
+ Method: http.MethodPatch,
+ Path: "/" + c.deviceID,
+ Body: bytes.NewReader(body),
+ ContentType: contentTypeJSON,
+ })
+}
diff --git a/internal/app/enaptercli/cmd_devices.go b/internal/app/enaptercli/cmd_devices.go
deleted file mode 100644
index 7782b4a..0000000
--- a/internal/app/enaptercli/cmd_devices.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package enaptercli
-
-import "github.com/urfave/cli/v2"
-
-type cmdDevices struct {
- cmdBase
- hardwareID string
-}
-
-func buildCmdDevices() *cli.Command {
- return &cli.Command{
- Name: "devices",
- Usage: "Device information and management commands.",
- Subcommands: []*cli.Command{
- buildCmdDevicesUpload(),
- buildCmdDevicesLogs(),
- buildCmdDevicesUploadLogs(),
- buildCmdDevicesExecute(),
- },
- }
-}
-
-func (c *cmdDevices) Flags() []cli.Flag {
- flags := c.cmdBase.Flags()
- flags = append(flags, &cli.StringFlag{
- Name: "hardware-id",
- Usage: "Hardware ID of the device; can be obtained in cloud.enapter.com",
- Required: true,
- Destination: &c.hardwareID,
- })
- return flags
-}
diff --git a/internal/app/enaptercli/cmd_devices_execute.go b/internal/app/enaptercli/cmd_devices_execute.go
deleted file mode 100644
index 6f15714..0000000
--- a/internal/app/enaptercli/cmd_devices_execute.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/publichttp"
-)
-
-type cmdDevicesExecute struct {
- cmdDevices
- commandName string
- arguments string
- showProgress bool
-}
-
-func buildCmdDevicesExecute() *cli.Command {
- cmd := &cmdDevicesExecute{}
-
- return &cli.Command{
- Name: "execute",
- Usage: "Execute command on device",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.execute(cliCtx.Context)
- },
- }
-}
-
-func (c *cmdDevicesExecute) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.StringFlag{
- Name: "command",
- Usage: "Command name",
- Required: true,
- Destination: &c.commandName,
- },
- &cli.StringFlag{
- Name: "arguments",
- Usage: "Command arguments as JSON object",
- Destination: &c.arguments,
- },
- &cli.BoolFlag{
- Name: "show-progress",
- Usage: "Enable in-progress responses streaming",
- Destination: &c.showProgress,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesExecute) execute(ctx context.Context) error {
- transport := publichttp.NewAuthTokenTransport(http.DefaultTransport, c.token)
- client, err := publichttp.NewClientWithURL(&http.Client{Transport: transport}, c.apiHost)
- if err != nil {
- return fmt.Errorf("create http client: %w", err)
- }
-
- var arguments map[string]interface{}
- if c.arguments != "" {
- if err := json.Unmarshal([]byte(c.arguments), &arguments); err != nil {
- return fmt.Errorf("parse arguments: %w", err)
- }
- }
-
- query := publichttp.CommandQuery{
- HardwareID: c.hardwareID,
- CommandName: c.commandName,
- Arguments: arguments,
- }
-
- if c.showProgress {
- return c.executeWithProgress(ctx, client, query)
- }
-
- response, err := client.Commands.Execute(ctx, query)
- if err != nil {
- return err
- }
- return c.print(response)
-}
-
-func (c *cmdDevicesExecute) executeWithProgress(
- ctx context.Context, client *publichttp.Client,
- query publichttp.CommandQuery,
-) error {
- progressCh, err := client.Commands.ExecuteWithProgress(ctx, query)
- if err != nil {
- return err
- }
-
- for progress := range progressCh {
- if progress.Error != nil {
- return progress.Error
- }
- err := c.print(progress.CommandResponse)
- if err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (c *cmdDevicesExecute) print(r publichttp.CommandResponse) error {
- s, err := json.Marshal(r)
- if err != nil {
- return fmt.Errorf("format response: %w", err)
- }
- fmt.Fprintln(c.writer, string(s))
- return nil
-}
diff --git a/internal/app/enaptercli/cmd_devices_execute_test.go b/internal/app/enaptercli/cmd_devices_execute_test.go
deleted file mode 100644
index 3b69ecc..0000000
--- a/internal/app/enaptercli/cmd_devices_execute_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package enaptercli_test
-
-import (
- "bytes"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-func TestDeviceExecute(t *testing.T) {
- t.Run("simple", func(t *testing.T) {
- basePath := "testdata/device_execute/simple"
- showProgress := false
- testDeviceExecute(t, basePath, showProgress, http.StatusOK)
- })
-
- t.Run("progress", func(t *testing.T) {
- basePath := "testdata/device_execute/progress"
- showProgress := true
- testDeviceExecute(t, basePath, showProgress, http.StatusOK)
- })
-
- t.Run("error", func(t *testing.T) {
- basePath := "testdata/device_execute/error"
- showProgress := false
- testDeviceExecute(t, basePath, showProgress, http.StatusForbidden)
- })
-}
-
-func testDeviceExecute(
- t *testing.T, basePath string, showProgress bool, statusCode int,
-) {
- resp := readFileLines(t, filepath.Join(basePath, "responses"))
- server := startExecuteTestServer(showProgress, statusCode, resp)
- defer server.Close()
-
- args := []string{"enapter", "devices", "execute"}
- args = append(args,
- "--token", faker.Word(),
- "--hardware-id", faker.Word(),
- "--command", faker.Word(),
- "--api-host", server.URL)
- if showProgress {
- args = append(args, "--show-progress")
- }
-
- checkExecuteTestAppOutput(t, basePath, args)
-}
-
-func readFileLines(t *testing.T, path string) [][]byte {
- f, err := os.ReadFile(path)
- require.NoError(t, err)
- return bytes.Split(f, []byte{'\n'})
-}
-
-func startExecuteTestServer(
- showProgress bool, statusCode int, responses [][]byte,
-) *httptest.Server {
- handler := func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(statusCode)
-
- for _, r := range responses {
- _, _ = w.Write(append(r, '\n'))
- if showProgress {
- w.(http.Flusher).Flush()
- }
- }
- }
- return httptest.NewServer(http.HandlerFunc(handler))
-}
-
-func checkExecuteTestAppOutput(t *testing.T, basePath string, args []string) {
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
-
- actual, err := io.ReadAll(app.Stdout())
- require.NoError(t, err)
-
- if appErr != nil {
- actual = append(actual, []byte("app exit with error: "+appErr.Error()+"\n")...)
- }
-
- expectedFileName := filepath.Join(basePath, "output")
- if update {
- err := os.WriteFile(expectedFileName, actual, 0o600)
- require.NoError(t, err)
- }
-
- expected, err := os.ReadFile(expectedFileName)
- require.NoError(t, err)
-
- require.Equal(t, string(expected), string(actual))
-}
diff --git a/internal/app/enaptercli/cmd_devices_logs.go b/internal/app/enaptercli/cmd_devices_logs.go
deleted file mode 100644
index 1436c40..0000000
--- a/internal/app/enaptercli/cmd_devices_logs.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-type cmdDevicesLogs struct {
- cmdDevices
-}
-
-func buildCmdDevicesLogs() *cli.Command {
- cmd := &cmdDevicesLogs{}
-
- return &cli.Command{
- Name: "logs",
- Usage: "Stream logs from a device",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesLogs) run(ctx context.Context, version string) error {
- writer, err := cloudapi.NewDeviceLogsWriter(c.websocketsURL, c.token,
- version, c.hardwareID, c.writeLog)
- if err != nil {
- return fmt.Errorf("create writer: %w", err)
- }
- return writer.Run(ctx)
-}
-
-func (c *cmdDevicesLogs) writeLog(topic, msg string) {
- fmt.Fprintf(c.writer, "[%s] %s\n", topic, msg)
-}
diff --git a/internal/app/enaptercli/cmd_devices_logs_test.go b/internal/app/enaptercli/cmd_devices_logs_test.go
deleted file mode 100644
index 250c353..0000000
--- a/internal/app/enaptercli/cmd_devices_logs_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//nolint:dupl // not a duplicate of `rules logs` command tests
-package enaptercli_test
-
-import (
- "strings"
- "testing"
-)
-
-func TestDeviceLogs(t *testing.T) {
- t.Run("simple", func(t *testing.T) {
- inputFileName := "testdata/device_logs/simple/input"
- untilLinePrefix := "[telemetry]"
- expectedFileName := "testdata/device_logs/simple/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("invalid token", func(t *testing.T) {
- inputFileName := "testdata/device_logs/disconnect/invalid_token/input"
- untilLinePrefix := "[connection]"
- expectedFileName := "testdata/device_logs/disconnect/invalid_token/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-
- t.Run("device not found", func(t *testing.T) {
- inputFileName := "testdata/device_logs/disconnect/device_not_found/input"
- untilLinePrefix := "[connection] disconnected"
- expectedFileName := "testdata/device_logs/disconnect/device_not_found/output"
- testDeviceLogs(t, inputFileName, untilLinePrefix, expectedFileName)
- })
-}
-
-func testDeviceLogs(t *testing.T, inputFileName, untilLinePrefix, expectedFileName string) {
- const hardwareID = "SIM-WTM"
-
- identifier := map[string]string{"channel": "DeviceChannel", "hardware_id": hardwareID}
-
- command := strings.Split("enapter devices logs", " ")
- command = append(command, "--hardware-id", hardwareID)
-
- testLogsCommand(t, inputFileName, untilLinePrefix, expectedFileName, identifier, command)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload.go b/internal/app/enaptercli/cmd_devices_upload.go
deleted file mode 100644
index d8c485e..0000000
--- a/internal/app/enaptercli/cmd_devices_upload.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package enaptercli
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-const deviceUploadDefaultTimeout = 30 * time.Second
-
-type cmdDevicesUpload struct {
- cmdDevices
- blueprintDir string
- timeout time.Duration
-}
-
-func buildCmdDevicesUpload() *cli.Command {
- cmd := &cmdDevicesUpload{}
-
- return &cli.Command{
- Name: "upload",
- Usage: "Upload blueprint to a device",
- Description: "Blueprint combines device capabilities declaration and Lua firmware for Enapter UCM. " +
- "The command updates device blueprint and uploads the firmware to the UCM. Learn more " +
- "about Enapter Blueprints at https://handbook.enapter.com/blueprints.",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.upload(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesUpload) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.DurationFlag{
- Name: "timeout",
- Usage: "Time to wait for blueprint uploading",
- Destination: &c.timeout,
- Value: deviceUploadDefaultTimeout,
- },
- &cli.StringFlag{
- Name: "blueprint-dir",
- Usage: "Directory which contains blueprint file",
- Required: true,
- Destination: &c.blueprintDir,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesUpload) upload(ctx context.Context, version string) error {
- if c.timeout != 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, c.timeout)
- defer cancel()
- }
-
- files, err := c.blueprintFilesList()
- if err != nil {
- return err
- }
-
- fmt.Fprintln(c.writer, "Blueprint files to be uploaded:")
- for _, name := range files {
- fmt.Fprintln(c.writer, "*", name)
- }
-
- zipBytes, err := c.blueprintZip()
- if err != nil {
- return err
- }
-
- onceWriter := &onceWriter{w: c.writer}
- transport := cloudapi.NewCredentialsTransport(http.DefaultTransport, c.token, version)
- transport = cloudapi.NewCLIMessageWriterTransport(transport, onceWriter)
- client := cloudapi.NewClientWithURL(&http.Client{Transport: transport}, c.graphqlURL)
-
- uploadData, uploadErrors, err := client.UploadBlueprint(ctx, c.hardwareID, zipBytes)
- if err != nil {
- return fmt.Errorf("do update: %w", err)
- }
-
- if len(uploadErrors) != 0 {
- for _, e := range uploadErrors {
- fmt.Fprintln(c.writer, "[ERROR]", e.Message)
- }
- return errFinishedWithError
- }
-
- fmt.Fprintln(c.writer, "upload started with operation id", uploadData.OperationID)
-
- err = client.WriteOperationLogs(ctx, c.hardwareID, uploadData.OperationID, c.writeLog)
- if err != nil {
- return fmt.Errorf("receive operation logs: %w", err)
- }
-
- fmt.Fprintln(c.writer, "Done!")
- return nil
-}
-
-func (c *cmdDevicesUpload) blueprintFilesList() ([]string, error) {
- var files []string
-
- err := filepath.Walk(c.blueprintDir,
- func(name string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if !info.IsDir() {
- files = append(files, name)
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
-
- return files, nil
-}
-
-func (c *cmdDevicesUpload) blueprintZip() ([]byte, error) {
- bpBytes, err := zipDir(c.blueprintDir)
- if err != nil {
- return nil, fmt.Errorf("failed to zip blueprint dir %q: %w", c.blueprintDir, err)
- }
-
- zipBuf := &bytes.Buffer{}
- zipBuf.WriteString("data:application/gzip;base64,")
- enc := base64.NewEncoder(base64.StdEncoding, zipBuf)
- _, err = enc.Write(bpBytes)
- if err != nil {
- return nil, fmt.Errorf("failed to encode blueprint as base64: %w", err)
- }
-
- if err := enc.Close(); err != nil {
- return nil, fmt.Errorf("failed to encode blueprint as base64: %w", err)
- }
-
- return zipBuf.Bytes(), nil
-}
-
-func (c *cmdDevicesUpload) writeLog(operationID string, l cloudapi.OperationLog) {
- fmt.Fprintf(c.writer, "[#%s] %s [%s] %s\n", operationID, l.CreatedAt, l.Severity, l.Payload)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_logs.go b/internal/app/enaptercli/cmd_devices_upload_logs.go
deleted file mode 100644
index 7051808..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_logs.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package enaptercli
-
-import (
- "context"
- "fmt"
- "net/http"
- "time"
-
- "github.com/urfave/cli/v2"
-
- "github.com/enapter/enapter-cli/internal/cloudapi"
-)
-
-type cmdDevicesUploadLogs struct {
- cmdDevices
- operationID string
- timeout time.Duration
-}
-
-func buildCmdDevicesUploadLogs() *cli.Command {
- cmd := &cmdDevicesUploadLogs{}
-
- return &cli.Command{
- Name: "upload-logs",
- Usage: "Show blueprint uploading logs",
- CustomHelpTemplate: cmd.HelpTemplate(),
- Flags: cmd.Flags(),
- Before: cmd.Before,
- Action: func(cliCtx *cli.Context) error {
- return cmd.run(cliCtx.Context, cliCtx.App.Version)
- },
- }
-}
-
-func (c *cmdDevicesUploadLogs) Flags() []cli.Flag {
- flags := c.cmdDevices.Flags()
- flags = append(flags,
- &cli.DurationFlag{
- Name: "timeout",
- Usage: "Time to wait for blueprint uploading",
- Destination: &c.timeout,
- Value: deviceUploadDefaultTimeout,
- },
- &cli.StringFlag{
- Name: "operation-id",
- Usage: "Uploading operation ID (optional)",
- Destination: &c.operationID,
- },
- )
- return flags
-}
-
-func (c *cmdDevicesUploadLogs) run(ctx context.Context, version string) error {
- if c.timeout != 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, c.timeout)
- defer cancel()
- }
-
- transport := cloudapi.NewCredentialsTransport(http.DefaultTransport, c.token, version)
- transport = cloudapi.NewCLIMessageWriterTransport(transport, &onceWriter{w: c.writer})
- client := cloudapi.NewClientWithURL(&http.Client{Transport: transport}, c.graphqlURL)
-
- if c.operationID != "" {
- return client.WriteOperationLogs(ctx, c.hardwareID, c.operationID, c.writeLog)
- }
-
- const lastOperationsNumber = 2
- return client.WriteLastOperationsLogs(ctx, c.hardwareID, lastOperationsNumber, c.writeLog)
-}
-
-func (c *cmdDevicesUploadLogs) writeLog(operationID string, l cloudapi.OperationLog) {
- fmt.Fprintf(c.writer, "[#%s] %s [%s] %s\n", operationID, l.CreatedAt, l.Severity, l.Payload)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_logs_test.go b/internal/app/enaptercli/cmd_devices_upload_logs_test.go
deleted file mode 100644
index d574df4..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_logs_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package enaptercli_test
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-func TestDeviceUploadLogs(t *testing.T) {
- errorsDir := "testdata/device_upload_logs"
- dirs, err := os.ReadDir(errorsDir)
- require.NoError(t, err)
-
- for _, dir := range dirs {
- if !dir.IsDir() {
- continue
- }
-
- dir := dir
- t.Run(dir.Name(), func(t *testing.T) {
- testDeviceUploadLogs(t, filepath.Join(errorsDir, dir.Name()))
- })
- }
-}
-
-type devicesUploadLogsTestSettings struct {
- OperationID string `json:"operation_id"`
- HardwareID string `json:"hardware_id"`
- CliMessage string `json:"cli_message"`
- Token string `json:"-"`
-}
-
-func (s *devicesUploadLogsTestSettings) Fill(t *testing.T, dir string) {
- settingsBytes, err := os.ReadFile(filepath.Join(dir, "settings.json"))
- require.NoError(t, err)
- require.NoError(t, json.Unmarshal(settingsBytes, s))
- s.Token = faker.Word()
-}
-
-func testDeviceUploadLogs(t *testing.T, dir string) {
- var settings devicesUploadLogsTestSettings
- settings.Fill(t, dir)
-
- reqs := byteSliceSliceFromFile(t, filepath.Join(dir, "requests"))
- resps := byteSliceSliceFromFile(t, filepath.Join(dir, "responses"))
-
- srv := startTestServer(reqs, resps, settings.CliMessage)
- defer srv.Close()
-
- args := strings.Split("enapter devices upload-logs", " ")
- args = append(args,
- "--token", settings.Token,
- "--hardware-id", settings.HardwareID,
- "--gql-api-url", srv.URL)
- if settings.OperationID != "" {
- args = append(args, "--operation-id", settings.OperationID)
- }
-
- checkTestAppOutput(t, dir, args, reqs)
-}
diff --git a/internal/app/enaptercli/cmd_devices_upload_test.go b/internal/app/enaptercli/cmd_devices_upload_test.go
deleted file mode 100644
index 4cfe889..0000000
--- a/internal/app/enaptercli/cmd_devices_upload_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package enaptercli_test
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/bxcodec/faker/v3"
- "github.com/stretchr/testify/require"
-)
-
-const blueprintDir = "testdata/device_upload/simple/blueprint"
-
-func TestDeviceUpload(t *testing.T) {
- testdataDir := "testdata/device_upload"
- dirs, err := os.ReadDir(testdataDir)
- require.NoError(t, err)
-
- for _, dir := range dirs {
- if !dir.IsDir() {
- continue
- }
-
- dir := dir
- t.Run(dir.Name(), func(t *testing.T) {
- testDeviceUpload(t, filepath.Join(testdataDir, dir.Name()), blueprintDir)
- })
- }
-}
-
-func TestDeviceUploadBlueprintDirWithDot(t *testing.T) {
- testDeviceUpload(t, "testdata/device_upload/simple", "./"+blueprintDir)
-}
-
-func TestDeviceUploadWrongBlueprintDir(t *testing.T) {
- args := strings.Split("enapter devices upload --token token --hardware-id hardwareID "+
- "--gql-api-url apiURL --blueprint-dir wrong", " ")
- app := startTestApp(args...)
- defer app.Stop()
-
- appErr := app.Wait()
- require.EqualError(t, appErr, `lstat wrong: no such file or directory`)
-}
-
-type devicesUploadTestSettings struct {
- HardwareID string `json:"hardware_id"`
- CliMessage string `json:"cli_message"`
- Token string `json:"-"`
-}
-
-func (s *devicesUploadTestSettings) Fill(t *testing.T, dir string) {
- settingsBytes, err := os.ReadFile(filepath.Join(dir, "settings.json"))
- require.NoError(t, err)
- require.NoError(t, json.Unmarshal(settingsBytes, s))
- s.Token = faker.Word()
-}
-
-func testDeviceUpload(t *testing.T, dir, blueprintDir string) {
- var settings devicesUploadTestSettings
- settings.Fill(t, dir)
-
- reqs := byteSliceSliceFromFile(t, filepath.Join(dir, "requests"))
- resps := byteSliceSliceFromFile(t, filepath.Join(dir, "responses"))
-
- srv := startTestServer(reqs, resps, settings.CliMessage)
- defer srv.Close()
-
- args := strings.Split("enapter devices upload", " ")
- args = append(args,
- "--token", settings.Token,
- "--hardware-id", settings.HardwareID,
- "--blueprint-dir", blueprintDir,
- "--gql-api-url", srv.URL)
-
- checkTestAppOutput(t, dir, args, reqs)
-}
diff --git a/internal/app/enaptercli/cmd_logs_test.go b/internal/app/enaptercli/cmd_logs_test.go
deleted file mode 100644
index 0db80b7..0000000
--- a/internal/app/enaptercli/cmd_logs_test.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package enaptercli_test
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "reflect"
- "strings"
- "testing"
- "time"
-
- "github.com/bxcodec/faker/v3"
- "github.com/gorilla/websocket"
- "github.com/stretchr/testify/require"
-)
-
-func testLogsCommand(
- t *testing.T, inputFileName, untilLinePrefix, expectedFileName string,
- identifier map[string]string, args []string,
-) {
- token := faker.Word()
- messagesBytes, err := os.ReadFile(inputFileName)
- require.NoError(t, err)
- messages := bytes.Split(messagesBytes, []byte{'\n'})
-
- handleErrCh := make(chan string)
- wsPath, srv := startTestLogsServer(t, token, identifier, messages, handleErrCh)
- defer srv.Close()
-
- args = append(args, "--token", token, "--ws-api-url", wsPath)
- app := startTestApp(args...)
- defer app.Stop()
-
- actual := readOutputUntilLineOrError(t, app.Stdout(), untilLinePrefix, handleErrCh)
- if update {
- err := os.WriteFile(expectedFileName, []byte(actual), 0o600)
- require.NoError(t, err)
- }
-
- expected, err := os.ReadFile(expectedFileName)
- require.NoError(t, err)
- require.Equal(t, string(expected), actual)
-
- app.Stop()
- appErr := app.Wait()
- require.NoError(t, appErr)
-
- restOutput, err := io.ReadAll(app.Stdout())
- require.NoError(t, err)
- require.Empty(t, string(restOutput))
-}
-
-func startTestLogsServer(
- t *testing.T, token string, identifier map[string]string, messages [][]byte,
- handleErrCh chan<- string,
-) (string, *httptest.Server) {
- t.Helper()
-
- handler := buildTestLogsHandler(token, identifier, messages, handleErrCh)
- srv := httptest.NewServer(handler)
-
- u, err := url.Parse(srv.URL)
- require.NoError(t, err)
- u.Scheme = "ws"
-
- return u.String(), srv
-}
-
-func readOutputUntilLineOrError(
- t *testing.T, r *lineBuffer, prefix string, wsHandleErrCh <-chan string,
-) string {
- t.Helper()
-
- readStr, readErr := startBackgroundReadUntilLine(r, prefix)
-
- timer := time.NewTimer(5 * time.Second)
- select {
- case <-timer.C:
- require.Fail(t, "read output timed out")
- case errStr := <-wsHandleErrCh:
- require.Failf(t, "ws handler finished with error", errStr)
- case err := <-readErr:
- require.Failf(t, "read finished with error", err.Error())
- case s := <-readStr:
- return s
- }
-
- return ""
-}
-
-func startBackgroundReadUntilLine(r *lineBuffer, prefix string) (<-chan string, <-chan error) {
- readStr := make(chan string, 1)
- readErr := make(chan error, 1)
-
- go func() {
- buf := strings.Builder{}
-
- for {
- s, err := r.ReadLine()
- if err != nil {
- readErr <- err
- return
- }
-
- buf.WriteString(s)
-
- if strings.HasPrefix(s, prefix) {
- readStr <- buf.String()
- return
- }
- }
- }()
-
- return readStr, readErr
-}
-
-//nolint:funlen // because contains a lot of simple logged checks.
-func buildTestLogsHandler(
- token string, identifier map[string]string, messages [][]byte, handleErrCh chan<- string,
-) http.Handler {
- f := func(w http.ResponseWriter, r *http.Request) {
- reqToken := r.URL.Query().Get("token")
- if reqToken != token {
- w.WriteHeader(http.StatusBadRequest)
- handleErrCh <- fmt.Sprintf("unexpected token %q, should be %q", reqToken, token)
- return
- }
-
- upgrader := websocket.Upgrader{}
- c, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- handleErrCh <- fmt.Sprintf("failed to upgrade: %s", err)
- return
- }
- defer c.Close()
-
- msgType, msgBytes, err := c.ReadMessage()
- if err != nil {
- handleErrCh <- fmt.Sprintf("failed to read subscribe message: %s", err)
- return
- }
-
- if msgType != websocket.TextMessage {
- handleErrCh <- fmt.Sprintf("subscribe message should be text type [%d], but [%d]",
- websocket.TextMessage, msgType)
- return
- }
-
- subMsg := struct {
- Command string `json:"command"`
- Identifier string `json:"identifier"`
- }{}
- if err := json.Unmarshal(msgBytes, &subMsg); err != nil {
- handleErrCh <- fmt.Sprintf("failed to unmarshall subsribe message %q: %s", string(msgBytes), err.Error())
- return
- }
-
- if subMsg.Command != "subscribe" {
- handleErrCh <- fmt.Sprintf("this is not subscribe message, but %q", subMsg.Command)
- return
- }
-
- var reqIdentifier map[string]string
- if err := json.Unmarshal([]byte(subMsg.Identifier), &reqIdentifier); err != nil {
- handleErrCh <- fmt.Sprintf("failed to unmarshall subsribe message identifier %q: %s",
- subMsg.Identifier, err.Error())
- return
- }
-
- if !reflect.DeepEqual(identifier, reqIdentifier) {
- handleErrCh <- fmt.Sprintf("subsribe message identifier shoud be equal to %q, but %q",
- identifier, reqIdentifier)
- return
- }
-
- for _, m := range messages {
- if len(m) == 0 {
- continue
- }
- if err := c.WriteMessage(websocket.TextMessage, m); err != nil {
- handleErrCh <- fmt.Sprintf("failed to write message: %s", err.Error())
- return
- }
- }
-
- <-r.Context().Done()
- }
-
- return http.HandlerFunc(f)
-}
diff --git a/internal/app/enaptercli/cmd_rule_engine.go b/internal/app/enaptercli/cmd_rule_engine.go
new file mode 100644
index 0000000..e0e9645
--- /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/v3"
+)
+
+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(),
+ Commands: []*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..a2ce3cd
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..54e1668
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..283fb54
--- /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/v3"
+)
+
+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(),
+ Commands: []*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..db65c8d
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_create.go
@@ -0,0 +1,109 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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,
+ Action: cliflags.TrimSpaceAction(&c.slug),
+ },
+ &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(_ context.Context, _ *cli.Command, 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..495fc9b
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..006bd14
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_disable.go
@@ -0,0 +1,56 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleDisable) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &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..dfb1d41
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_enable.go
@@ -0,0 +1,56 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+func (c *cmdRuleEngineRuleEnable) Flags() []cli.Flag {
+ return append(c.cmdRuleEngineRule.Flags(),
+ &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..5ff57ea
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..4049959
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..17957e4
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_logs.go
@@ -0,0 +1,73 @@
+package enaptercli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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(ctx context.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(ctx, 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..e815257
--- /dev/null
+++ b/internal/app/enaptercli/cmd_rule_engine_rule_update.go
@@ -0,0 +1,70 @@
+package enaptercli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+
+ "github.com/enapter/enapter-cli/v3/internal/app/cliflags"
+)
+
+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(ctx context.Context, cliCmd *cli.Command) error {
+ return cmd.do(ctx, cliCmd)
+ },
+ }
+}
+
+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,
+ Action: cliflags.TrimSpaceAction(&c.slug),
+ },
+ )
+}
+
+func (c *cmdRuleEngineRuleUpdate) do(ctx context.Context, cliCmd *cli.Command) error {
+ payload := make(map[string]any)
+
+ if cliCmd.IsSet("slug") {
+ payload["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.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..b74756f
--- /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/v3"
+
+ "github.com/enapter/enapter-cli/v3/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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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(_ context.Context, _ *cli.Command, 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..57d9b43
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..1acd92a
--- /dev/null
+++ b/internal/app/enaptercli/cmd_site.go
@@ -0,0 +1,35 @@
+package enaptercli
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/urfave/cli/v3"
+)
+
+type cmdSite struct {
+ cmdBase
+}
+
+func buildCmdSite() *cli.Command {
+ cmd := &cmdSite{}
+ return &cli.Command{
+ Name: "site",
+ Usage: "Manage sites",
+ CustomHelpTemplate: cmd.SubcommandHelpTemplate(),
+ Commands: []*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..c884155
--- /dev/null
+++ b/internal/app/enaptercli/cmd_site_get.go
@@ -0,0 +1,47 @@
+package enaptercli
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/urfave/cli/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..a238b08
--- /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/v3"
+)
+
+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(ctx context.Context, _ *cli.Command) error {
+ return cmd.do(ctx)
+ },
+ }
+}
+
+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..efa60ee 100644
--- a/internal/app/enaptercli/execute.go
+++ b/internal/app/enaptercli/execute.go
@@ -1,69 +1,35 @@
package enaptercli
import (
- "archive/zip"
- "bytes"
- "io"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/urfave/cli/v2"
+ "github.com/urfave/cli/v3"
)
// NewApp creates a new Enapter CLI tool application instance.
-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.Commands = []*cli.Command{
- buildCmdDevices(),
- buildCmdRules(),
- }
-
- return app
-}
-
-func zipDir(path string) ([]byte, error) {
- buf := &bytes.Buffer{}
- myZip := zip.NewWriter(buf)
-
- path = strings.TrimPrefix(path, "./")
-
- err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if info.IsDir() {
- return nil
- }
- relPath := strings.TrimPrefix(filePath, path)
- relPath = strings.TrimPrefix(relPath, "/")
- zipFile, err := myZip.Create(relPath)
- if err != nil {
- return err
- }
- fsFile, err := os.Open(filePath)
- if err != nil {
- return err
- }
- _, err = io.Copy(zipFile, fsFile)
- if err != nil {
- return err
+func NewApp() *cli.Command {
+ cmd := &cli.Command{}
+
+ cmd.Name = "enapter3"
+ cmd.Usage = "Command Line Interface (CLI) for Enapter services."
+ cmd.Description = "The Enapter CLI requires an access token for authentication. " +
+ "You can obtain the token in your Enapter Cloud account settings."
+ cmd.CustomRootCommandHelpTemplate = cli.RootCommandHelpTemplate + enapterAPIEnvVarsHelp
+
+ cli.ShowSubcommandHelp = func(cmd *cli.Command) error {
+ tmpl := cmd.CustomHelpTemplate
+ if tmpl == "" {
+ tmpl = cli.SubcommandHelpTemplate
}
+ cli.HelpPrinter(cmd.Root().Writer, tmpl, cmd)
return nil
- })
- if err != nil {
- return nil, err
}
- if err := myZip.Close(); err != nil {
- return nil, err
+ cmd.Commands = []*cli.Command{
+ buildCmdSite(),
+ buildCmdDevice(),
+ buildCmdBlueprint(),
+ buildCmdRuleEngine(),
+ buildCmdConnection(),
}
- return buf.Bytes(), err
+ return cmd
}
diff --git a/internal/app/enaptercli/execute_test.go b/internal/app/enaptercli/execute_test.go
index 0feda1e..32dcdfb 100644
--- a/internal/app/enaptercli/execute_test.go
+++ b/internal/app/enaptercli/execute_test.go
@@ -1,28 +1,37 @@
package enaptercli_test
import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
"io"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "text/template"
+ "unicode/utf8"
"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 +42,151 @@ 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)
+ if !utf8.Valid(d) {
+ return "base64:" + base64.StdEncoding.EncodeToString(d)
+ }
+ 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/blueprints/with_ignore/.blueprintignore b/internal/app/enaptercli/testdata/blueprints/with_ignore/.blueprintignore
new file mode 100644
index 0000000..ef933e2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/with_ignore/.blueprintignore
@@ -0,0 +1,5 @@
+# Ignore build artifacts
+build/
+
+# Ignore log files
+*.log
diff --git a/internal/app/enaptercli/testdata/blueprints/with_ignore/build/output.bin b/internal/app/enaptercli/testdata/blueprints/with_ignore/build/output.bin
new file mode 100644
index 0000000..2126dc7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/with_ignore/build/output.bin
@@ -0,0 +1 @@
+this should be ignored too
diff --git a/internal/app/enaptercli/testdata/blueprints/with_ignore/debug.log b/internal/app/enaptercli/testdata/blueprints/with_ignore/debug.log
new file mode 100644
index 0000000..9a0adbc
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/with_ignore/debug.log
@@ -0,0 +1 @@
+this should be ignored
diff --git a/internal/app/enaptercli/testdata/blueprints/with_ignore/firmware.lua b/internal/app/enaptercli/testdata/blueprints/with_ignore/firmware.lua
new file mode 100644
index 0000000..aa6fd65
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/with_ignore/firmware.lua
@@ -0,0 +1 @@
+enapter.log("Hello from firmware.lua")
diff --git a/internal/app/enaptercli/testdata/blueprints/with_ignore/manifest.yml b/internal/app/enaptercli/testdata/blueprints/with_ignore/manifest.yml
new file mode 100644
index 0000000..fc8f08a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/blueprints/with_ignore/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..08600cf 100644
--- a/internal/app/enaptercli/testdata/helps/enapter
+++ b/internal/app/enaptercli/testdata/helps/enapter
@@ -1,18 +1,26 @@
NAME:
- enaptercli.test - Command line interface for Enapter services.
+ enapter3 - Command Line Interface (CLI) for Enapter services.
USAGE:
- enaptercli.test [global options] command [command options] [arguments...]
+ enapter3 [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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter blueprint b/internal/app/enaptercli/testdata/helps/enapter blueprint
new file mode 100644
index 0000000..22253cc
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 blueprint - Manage blueprints
+
+USAGE:
+ enapter3 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
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2cd50b6
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint download
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 blueprint download - Download the blueprint zip from the Platform
+
+USAGE:
+ enapter3 blueprint download [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --blueprint-id string, -b string Blueprint name or ID to download
+ --output string, -o string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..a7a2bd9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint get
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 blueprint get - Retrieve blueprint metadata
+
+USAGE:
+ enapter3 blueprint get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --blueprint-id string, -b string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..f98bb65
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 blueprint profiles - Manage blueprint profiles
+
+USAGE:
+ enapter3 blueprint profiles [command [command options]]
+
+COMMANDS:
+ download Download profiles zip from the Platform
+ upload Upload profiles to the Platform
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2f741f9
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles download
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 blueprint profiles download - Download profiles zip from the Platform
+
+USAGE:
+ enapter3 blueprint profiles download [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --output string, -o string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..bb5acc0
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint profiles upload
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 blueprint profiles upload - Upload profiles to the Platform
+
+USAGE:
+ enapter3 blueprint profiles upload [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --path string, -p string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..f0b6363
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter blueprint upload
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 blueprint upload - Upload the blueprint to the Platform
+
+USAGE:
+ enapter3 blueprint upload [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --path string, -p string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
diff --git a/internal/app/enaptercli/testdata/helps/enapter connection b/internal/app/enaptercli/testdata/helps/enapter connection
new file mode 100644
index 0000000..90aed4a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection
@@ -0,0 +1,14 @@
+NAME:
+ enapter3 connection - Manage connections to Enapter Cloud and Gateways
+
+USAGE:
+ enapter3 connection [command [command options]]
+
+COMMANDS:
+ add Add a new connection
+ remove Remove a connection
+ list List all connections
+ set-default Set default connection
+
+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..06da1a4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection add
@@ -0,0 +1,14 @@
+NAME:
+ enapter3 connection add - Add a new connection
+
+USAGE:
+ enapter3 connection add [options]
+
+OPTIONS:
+ --name string Connection name
+ --gateway Indicates that the connection is to a Gateway
+ --url string Enapter API base URL (default: "https://api.enapter.com")
+ --token string Enapter API access token
+ --site-id string If specified, the connection will be limited to this site (available only for Cloud connections)
+ --allow-insecure Allow insecure connections to the Enapter API
+ --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..322b98e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection list
@@ -0,0 +1,8 @@
+NAME:
+ enapter3 connection list - List all connections
+
+USAGE:
+ enapter3 connection list [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..f40b8c8
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection remove
@@ -0,0 +1,9 @@
+NAME:
+ enapter3 connection remove - Remove a connection
+
+USAGE:
+ enapter3 connection remove [options]
+
+OPTIONS:
+ --name string 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..0123512
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter connection set-default
@@ -0,0 +1,9 @@
+NAME:
+ enapter3 connection set-default - Set default connection
+
+USAGE:
+ enapter3 connection set-default [options]
+
+OPTIONS:
+ --name string 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..7b18f6b
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device
@@ -0,0 +1,29 @@
+NAME:
+ enapter3 device - Manage devices
+
+USAGE:
+ enapter3 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
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..e96c1cf
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device change-blueprint
@@ -0,0 +1,22 @@
+NAME:
+ enapter3 device change-blueprint - Change device blueprint
+
+USAGE:
+ enapter3 device change-blueprint [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --blueprint-id string, -b string blueprint ID to use as new device blueprint
+ --blueprint-path string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..ec3976f
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 device command - Manage device commands
+
+USAGE:
+ enapter3 device command [command [command options]]
+
+COMMANDS:
+ execute Execute a device command
+ list List device command executions
+ get Retrieve a device command execution
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..5024b86
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command execute
@@ -0,0 +1,22 @@
+NAME:
+ enapter3 device command execute - Execute a device command
+
+USAGE:
+ enapter3 device command execute [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --name string Command name
+ --arguments string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..905e822
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command get
@@ -0,0 +1,22 @@
+NAME:
+ enapter3 device command get - Retrieve a device command execution
+
+USAGE:
+ enapter3 device command get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --execution-id string Execution ID
+ --expand string [ --expand string ] 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..0143579
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device command list
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 device command list - List device command executions
+
+USAGE:
+ enapter3 device command list [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..82c3ad2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device communication-config
@@ -0,0 +1,18 @@
+NAME:
+ enapter3 device communication-config - Manage device communication config
+
+USAGE:
+ enapter3 device communication-config [command [command options]]
+
+COMMANDS:
+ generate Generate a new communication config for device
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..0aa1088
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device communication-config generate
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device communication-config generate - Generate a new communication config for device
+
+USAGE:
+ enapter3 device communication-config generate [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --protocol string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..b8f8e59
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 device create - Create devices of different types
+
+USAGE:
+ enapter3 device create [command [command options]]
+
+COMMANDS:
+ standalone Create a new standalone device
+ lua-device Create a new Lua device
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..0175065
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create lua-device
@@ -0,0 +1,24 @@
+NAME:
+ enapter3 device create lua-device - Create a new Lua device
+
+USAGE:
+ enapter3 device create lua-device [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --runtime-id string, -r string UCM device ID where the new Lua device will run
+ --device-name string, -n string name for the new Lua device
+ --device-slug string slug for the new Lua device
+ --blueprint-id string, -b string blueprint ID to use for the new Lua device
+ --blueprint-path string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..947b8a4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device create standalone
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device create standalone - Create a new standalone device
+
+USAGE:
+ enapter3 device create standalone [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string, -s string Site ID where the device will be created
+ --device-name string, -n string Name for the new device
+ --device-slug string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..09f1aa1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device delete
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 device delete - Delete a device
+
+USAGE:
+ enapter3 device delete [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..bc99259
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device get
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device get - Retrieve device information
+
+USAGE:
+ enapter3 device get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --expand string [ --expand string ] 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..d601b39
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device list
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device list - List user devices ordered by device ID
+
+USAGE:
+ enapter3 device list [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --expand string [ --expand string ] Comma-separated list of expanded device information (supported values: connectivity, manifest, properties, communication, site)
+ --limit int 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..1d55201
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device logs
@@ -0,0 +1,28 @@
+NAME:
+ enapter3 device logs - Show device logs
+
+USAGE:
+ enapter3 device logs [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --follow, -f Follow the log output
+ --received-at-gte time From timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)
+ --received-at-lt time To timestamp in RFC 3339 format (e.g. 2006-01-02T15:04:05Z)
+ --limit int, -l int Maximum number of logs to retrieve (default: 0)
+ --offset int, -o int Number of logs to skip when retrieving (default: 0)
+ --severity string, -s string Filter logs by severity
+ --order string Order logs by criteria (RECEIVED_AT_ASC[default], RECEIVED_AT_DESC)
+ --retention string Filter logs by retention (ALL[default], PERSISTENT, EPHEMERAL)
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..943d6ed
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device monitor
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device monitor - Monitor device traffic
+
+USAGE:
+ enapter3 device monitor [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --include-runtime Monitor device's runtime traffic too
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..a562248
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device run-terminal
@@ -0,0 +1,23 @@
+NAME:
+ enapter3 device run-terminal - Run new remote terminal session
+
+USAGE:
+ enapter3 device run-terminal [options]
+
+DESCRIPTION:
+ Remote terminal feature should be enabled in gateway settings. Use Ctrl+] sequence to force connection close.
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..34ff212
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device telemetry
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 device telemetry - Show device telemetry
+
+USAGE:
+ enapter3 device telemetry [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --follow, -f Follow the telemetry output
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..4f16b52
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter device update
@@ -0,0 +1,22 @@
+NAME:
+ enapter3 device update - Update a device
+
+USAGE:
+ enapter3 device update [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --device-id string, -d string Device ID
+ --name string Device name
+ --slug string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2ae91b2
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 rule-engine - Manage the rule engine
+
+USAGE:
+ enapter3 rule-engine [command [command options]]
+
+COMMANDS:
+ get Retrieve the rule engine
+ suspend Suspend execution of rules
+ resume Resume execution of rules
+ rule Manage rules
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..f8e5f6e
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine get
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 rule-engine get - Retrieve the rule engine
+
+USAGE:
+ enapter3 rule-engine get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..8c122bc
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine resume
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 rule-engine resume - Resume execution of rules
+
+USAGE:
+ enapter3 rule-engine resume [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2623358
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule
@@ -0,0 +1,26 @@
+NAME:
+ enapter3 rule-engine rule - Manage rules
+
+USAGE:
+ enapter3 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
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2965dc7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule create
@@ -0,0 +1,24 @@
+NAME:
+ enapter3 rule-engine rule create - Create a new rule
+
+USAGE:
+ enapter3 rule-engine rule create [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --slug string Slug for the new rule
+ --script string Path to the file containing the script code
+ --runtime-version string Version of the runtime to use for the script execution (default: "V3")
+ --exec-interval duration How frequently to execute the script (compatible only with runtime version 1) in duration format (e.g., 5s, 2m) (default: 0s)
+ --disable Disable the rule upon creation
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..5b7e9a1
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule delete
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 rule-engine rule delete - Delete a rule
+
+USAGE:
+ enapter3 rule-engine rule delete [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..56ed9e7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule disable
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 rule-engine rule disable - Disable one or more rules
+
+USAGE:
+ enapter3 rule-engine rule disable [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string [ --rule-id string ] 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..b8e2012
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule enable
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 rule-engine rule enable - Enable one or more rules
+
+USAGE:
+ enapter3 rule-engine rule enable [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string [ --rule-id string ] 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..15a00f4
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule get
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 rule-engine rule get - Retrieve a rule
+
+USAGE:
+ enapter3 rule-engine rule get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..e5ac7f7
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule list
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 rule-engine rule list - List rules
+
+USAGE:
+ enapter3 rule-engine rule list [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..ad28a33
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule logs
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 rule-engine rule logs - Show rule logs
+
+USAGE:
+ enapter3 rule-engine rule logs [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string rule ID
+ --follow, -f follow the log output
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..2538272
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update
@@ -0,0 +1,21 @@
+NAME:
+ enapter3 rule-engine rule update - Update a rule
+
+USAGE:
+ enapter3 rule-engine rule update [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string Rule ID or slug to update
+ --slug string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..dcd81d0
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine rule update-script
@@ -0,0 +1,23 @@
+NAME:
+ enapter3 rule-engine rule update-script - Update the script of a rule
+
+USAGE:
+ enapter3 rule-engine rule update-script [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string Site ID
+ --rule-id string Rule ID or slug to update
+ --script string Path to a file containing the script code
+ --runtime-version string Version of the runtime to use for the script execution (default: "V3")
+ --exec-interval duration How frequently to execute the script (compatible only with runtime version 1) in duration format (e.g., 5s, 2m) (default: 0s)
+ --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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..4d95226
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter rule-engine suspend
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 rule-engine suspend - Suspend execution of rules
+
+USAGE:
+ enapter3 rule-engine suspend [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..50303ca
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 site - Manage sites
+
+USAGE:
+ enapter3 site [command [command options]]
+
+COMMANDS:
+ list List user sites
+ get Get a site
+
+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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..ec0b156
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site get
@@ -0,0 +1,19 @@
+NAME:
+ enapter3 site get - Get a site
+
+USAGE:
+ enapter3 site get [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --site-id string 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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..9f5bd26
--- /dev/null
+++ b/internal/app/enaptercli/testdata/helps/enapter site list
@@ -0,0 +1,20 @@
+NAME:
+ enapter3 site list - List user sites
+
+USAGE:
+ enapter3 site list [options]
+
+OPTIONS:
+ --connection string, -c string Name of the connection to use [$ENAPTER3_CONNECTION]
+ --api-allow-insecure Allow insecure connections to the Enapter API [$ENAPTER3_API_ALLOW_INSECURE]
+ --verbose Log extra details about the operation
+ --my-sites Returns only sites where user is owner or installer
+ --limit int 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)
+ ENAPTER3_CONNECTION Name of the connection to use
+
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/blueprint_upload_with_blueprintignore/cmd.tmpl b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/cmd.tmpl
new file mode 100644
index 0000000..921518a
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/cmd.tmpl
@@ -0,0 +1,2 @@
+enapter3 connection add --name my-conn --url {{.URL}} --token {{.Token}}
+enapter3 blueprint upload --connection my-conn --path ./testdata/blueprints/with_ignore
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/out b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/out
new file mode 100644
index 0000000..7223181
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/out
@@ -0,0 +1 @@
+{ "blueprint": {"id": "test_blueprint_id"} }
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/req_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/req_0
new file mode 100644
index 0000000..565e16c
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/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": "base64:UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAAMAAAAZmlybXdhcmUubHVhSs1LLChJLdLLyU/XUPJIzcnJV0grys9VSMssyi1PLErVyylNVNLkAgQAAP//UEsHCNL3diotAAAAJwAAAFBLAwQUAAgACAAAAAAAAAAAAAAAAAAAAAAADAAAAG1hbmlmZXN0LnltbBTKy6nDQAwF0L2quBX4PchONWSXAoxiX4NAMxbzSXD3wdvDecdkNq9j7clNsfPjG/8ey7/s3jPsWqsVKl5eMojnNJE26/BCFWBcSUVME+DM4WftNwOHBxWHt/K1xuUevwAAAP//UEsHCCoRGUFlAAAAbAAAAFBLAQIUABQACAAIAAAAAADS93YqLQAAACcAAAAMAAAAAAAAAAAAAAAAAAAAAABmaXJtd2FyZS5sdWFQSwECFAAUAAgACAAAAAAAKhEZQWUAAABsAAAADAAAAAAAAAAAAAAAAABnAAAAbWFuaWZlc3QueW1sUEsFBgAAAAACAAIAdAAAAAYBAAAAAA=="
+}
\ No newline at end of file
diff --git a/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/resp_0 b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/resp_0
new file mode 100644
index 0000000..7223181
--- /dev/null
+++ b/internal/app/enaptercli/testdata/http_req_resp/blueprint_upload_with_blueprintignore/resp_0
@@ -0,0 +1 @@
+{ "blueprint": {"id": "test_blueprint_id"} }
\ No newline at end of file
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..565e16c
--- /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": "base64:UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAAMAAAAZmlybXdhcmUubHVhSs1LLChJLdLLyU/XUPJIzcnJV0grys9VSMssyi1PLErVyylNVNLkAgQAAP//UEsHCNL3diotAAAAJwAAAFBLAwQUAAgACAAAAAAAAAAAAAAAAAAAAAAADAAAAG1hbmlmZXN0LnltbBTKy6nDQAwF0L2quBX4PchONWSXAoxiX4NAMxbzSXD3wdvDecdkNq9j7clNsfPjG/8ey7/s3jPsWqsVKl5eMojnNJE26/BCFWBcSUVME+DM4WftNwOHBxWHt/K1xuUevwAAAP//UEsHCCoRGUFlAAAAbAAAAFBLAQIUABQACAAIAAAAAADS93YqLQAAACcAAAAMAAAAAAAAAAAAAAAAAAAAAABmaXJtd2FyZS5sdWFQSwECFAAUAAgACAAAAAAAKhEZQWUAAABsAAAADAAAAAAAAAAAAAAAAABnAAAAbWFuaWZlc3QueW1sUEsFBgAAAAACAAIAdAAAAAYBAAAAAA=="
+}
\ 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
-}