From 4c3383530ad106f59c342f2792408b83e3bb94f3 Mon Sep 17 00:00:00 2001 From: Alex Bucknall Date: Fri, 5 Dec 2025 11:28:48 +0000 Subject: [PATCH 1/4] feat: initial Notehub CLI V2 --- .gitignore | 3 +- go.mod | 65 +- go.sum | 57 ++ notehub/auth.go | 98 ---- notehub/build | 1 - notehub/cmd/auth.go | 327 +++++++++++ notehub/cmd/config.go | 324 ++++++++++ notehub/cmd/device.go | 685 +++++++++++++++++++++ notehub/cmd/dfu.go | 627 ++++++++++++++++++++ notehub/cmd/explore.go | 159 +++++ notehub/cmd/fleet.go | 615 +++++++++++++++++++ notehub/{app.go => cmd/helpers.go} | 251 +++++--- notehub/cmd/product.go | 168 ++++++ notehub/cmd/project.go | 322 ++++++++++ notehub/cmd/provision.go | 89 +++ notehub/{ => cmd}/req.go | 71 ++- notehub/cmd/request.go | 90 +++ notehub/cmd/root.go | 164 ++++++ notehub/cmd/route.go | 670 +++++++++++++++++++++ notehub/{ => cmd}/trace.go | 79 ++- notehub/cmd/upload.go | 168 ++++++ notehub/cmd/vars.go | 171 ++++++ notehub/doc/CLI.md | 914 +++++++++++++++++++++++++++++ notehub/explore.go | 93 --- notehub/main.go | 462 +-------------- notehub/vars.go | 142 ----- 26 files changed, 5894 insertions(+), 921 deletions(-) delete mode 100644 notehub/auth.go delete mode 100644 notehub/build create mode 100644 notehub/cmd/auth.go create mode 100644 notehub/cmd/config.go create mode 100644 notehub/cmd/device.go create mode 100644 notehub/cmd/dfu.go create mode 100644 notehub/cmd/explore.go create mode 100644 notehub/cmd/fleet.go rename notehub/{app.go => cmd/helpers.go} (50%) create mode 100644 notehub/cmd/product.go create mode 100644 notehub/cmd/project.go create mode 100644 notehub/cmd/provision.go rename notehub/{ => cmd}/req.go (75%) create mode 100644 notehub/cmd/request.go create mode 100644 notehub/cmd/root.go create mode 100644 notehub/cmd/route.go rename notehub/{ => cmd}/trace.go (67%) create mode 100644 notehub/cmd/upload.go create mode 100644 notehub/cmd/vars.go create mode 100644 notehub/doc/CLI.md delete mode 100644 notehub/explore.go delete mode 100644 notehub/vars.go diff --git a/.gitignore b/.gitignore index a0c4fb7..7b58d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ notecard-schema/ dist/ +bin/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/go.mod b/go.mod index f514ac1..6ef7c9a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/note-cli -go 1.15 +go 1.23.0 + +toolchain go1.23.3 replace github.com/blues/note-cli/lib => ./lib @@ -15,6 +17,64 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 ) +require ( + github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/creack/pty v1.1.9 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/frankban/quicktest v1.14.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofrs/flock v0.7.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/pty v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/shoenig/test v1.7.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect + github.com/yuin/goldmark v1.4.13 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b // indirect + golang.org/x/term v0.33.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/errgo.v2 v2.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + periph.io/x/conn/v3 v3.7.0 // indirect + periph.io/x/d2xx v0.1.0 // indirect + periph.io/x/periph v3.6.2+incompatible // indirect +) + require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/golang/snappy v0.0.4 @@ -24,8 +84,9 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil/v3 v3.24.4 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/spf13/cobra v1.10.1 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect go.bug.st/serial v1.6.2 - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.34.0 // indirect periph.io/x/host/v3 v3.8.2 // indirect ) diff --git a/go.sum b/go.sum index 54c87ec..d61cad4 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,24 @@ github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrU github.com/blues/note-go v1.5.0/go.mod h1:F66ZqObdOhxRRXIwn9+YhVGqB93jMAnqlO2ibwMa998= github.com/blues/note-go v1.7.4 h1:AqeU6HXkCa7FwDsAao49H6DdTTtNNGJYjGwevZi4Shc= github.com/blues/note-go v1.7.4/go.mod h1:GfslvbmFus7z05P1YykcbMedTKTuDNTf8ryBb1Qjq/4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -21,9 +28,16 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= @@ -37,8 +51,11 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -47,6 +64,10 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/shirou/gopsutil/v3 v3.21.6/go.mod h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88= @@ -58,6 +79,20 @@ github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+l github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -70,6 +105,10 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= @@ -78,12 +117,19 @@ github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZF github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.bug.st/serial v1.3.4/go.mod h1:z8CesKorE90Qr/oRSJiEuvzYRKol9r/anJZEb5kt304= go.bug.st/serial v1.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -100,10 +146,21 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/notehub/auth.go b/notehub/auth.go deleted file mode 100644 index 443e9f0..0000000 --- a/notehub/auth.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2017 Blues Inc. All rights reserved. -// Use of this source code is governed by licenses granted by the -// copyright holder including that found in the LICENSE file. - -package main - -import ( - "fmt" - - "github.com/blues/note-cli/lib" - "github.com/blues/note-go/notehub" -) - -// Sign into the notehub account with a personal access token -func authSignInToken(personalAccessToken string) error { - // TODO: maybe call configInit() to set defaults? - config, err := lib.GetConfig() - if err != nil { - return err - } - - // Print hub if not the default - fmt.Printf("notehub: %s\n", config.Hub) - - email, err := lib.IntrospectToken(config.Hub, personalAccessToken) - if err != nil { - return err - } - - config.SetDefaultCredentials(personalAccessToken, email, nil) - - if err := config.Write(); err != nil { - return err - } - - // Done - fmt.Printf("signed in successfully with token\n") - return nil -} - -// Sign into the Notehub account with browser-based OAuth2 flow -func authSignIn() error { - - // load config - config, err := lib.GetConfig() - if err != nil { - return err - } - - credentials := config.DefaultCredentials() - - // if signed in with an access token via OAuth, then revoke the access token - // we don't want to revoke a PAT because the user explicitly set an - // expiration date on that token - if credentials != nil && credentials.IsOAuthAccessToken() { - if err := config.RemoveDefaultCredentials(); err != nil { - return err - } - } - - // initiate the browser-based OAuth2 login flow - accessToken, err := notehub.InitiateBrowserBasedLogin(config.Hub) - if err != nil { - return fmt.Errorf("authentication failed: %w", err) - } - - config.SetDefaultCredentials(accessToken.AccessToken, accessToken.Email, &accessToken.ExpiresAt) - - // save the config with the new credentials - if err := config.Write(); err != nil { - return err - } - - // print out information about the session - if accessToken != nil { - fmt.Printf("%s\n", banner()) - fmt.Printf("signed in as %s\n", accessToken.Email) - fmt.Printf("token expires at %s\n", accessToken.ExpiresAt.Format("2006-01-02 15:04:05 MST")) - } - - // Done - return nil -} - -// Banner for authentication -// http://patorjk.com/software/taag -// "Big" font - -func banner() (s string) { - s += " _ _ _ \r\n" - s += " | | | | | | \r\n" - s += " _ __ ___ | |_ ___| |__ _ _| |__ \r\n" - s += "| '_ \\ / _ \\| __/ _ \\ '_ \\| | | | '_ \\ \r\n" - s += "| | | | (_) | || __/ | | | |_| | |_) | \r\n" - s += "|_| |_|\\___/ \\__\\___|_| |_|\\__,_|_.__/ \r\n" - s += "\r\n" - return -} diff --git a/notehub/build b/notehub/build deleted file mode 100644 index 6d49256..0000000 --- a/notehub/build +++ /dev/null @@ -1 +0,0 @@ -Sun Mar 6 10:36:20 EST 2022 diff --git a/notehub/cmd/auth.go b/notehub/cmd/auth.go new file mode 100644 index 0000000..1d2e6e0 --- /dev/null +++ b/notehub/cmd/auth.go @@ -0,0 +1,327 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/blues/note-go/notehub" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Auth command flags +var ( + flagSetProject string +) + +// authCmd represents the auth command +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + Long: `Commands for signing in, signing out, and managing authentication tokens.`, +} + +// signinCmd represents the signin command +var signinCmd = &cobra.Command{ + Use: "signin", + Short: "Sign in to Notehub", + Long: `Sign in to Notehub using browser-based OAuth2 flow.`, + RunE: func(cmd *cobra.Command, args []string) error { + credentials, err := GetHubCredentials() + if err != nil { + return err + } + + // if signed in with an access token via OAuth, then revoke the access token + // we don't want to revoke a PAT because the user explicitly set an + // expiration date on that token + if credentials != nil && credentials.IsOAuthAccessToken() { + if err := RemoveHubCredentials(); err != nil { + return err + } + } + + // initiate the browser-based OAuth2 login flow + hub := GetHub() + accessToken, err := notehub.InitiateBrowserBasedLogin(hub) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + // save the credentials + if err := SetHubCredentials(accessToken.AccessToken, accessToken.Email, &accessToken.ExpiresAt); err != nil { + return err + } + + // print out information about the session + if accessToken != nil { + fmt.Printf("%s\n", banner()) + fmt.Printf("signed in as %s\n", accessToken.Email) + fmt.Printf("token expires at %s\n", accessToken.ExpiresAt.Format("2006-01-02 15:04:05 MST")) + } + + // Set project if provided via flag or prompt for selection + if err := handleProjectSelection(flagSetProject); err != nil { + return err + } + + return nil + }, +} + +// signinTokenCmd represents the signin-token command +var signinTokenCmd = &cobra.Command{ + Use: "signin-token [token]", + Short: "Sign in with a personal access token", + Long: `Sign in to Notehub using a personal access token.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personalAccessToken := args[0] + + hub := GetHub() + // Print hub if not the default + fmt.Printf("notehub: %s\n", hub) + + email, err := IntrospectToken(hub, personalAccessToken) + if err != nil { + return err + } + + if err := SetHubCredentials(personalAccessToken, email, nil); err != nil { + return err + } + + // Done + fmt.Printf("signed in successfully with token\n") + + // Set project if provided via flag or prompt for selection + if err := handleProjectSelection(flagSetProject); err != nil { + return err + } + + return nil + }, +} + +// signoutCmd represents the signout command +var signoutCmd = &cobra.Command{ + Use: "signout", + Short: "Sign out of Notehub", + Long: `Sign out of Notehub and remove stored credentials.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := RemoveHubCredentials(); err != nil { + return err + } + + // Also clear project setting + viper.Set("project", "") + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("signed out successfully\n") + return nil + }, +} + +// tokenCmd represents the token command +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Display the current authentication token", + Long: `Display the current authentication token for the signed-in account.`, + RunE: func(cmd *cobra.Command, args []string) error { + credentials, err := GetHubCredentials() + if err != nil { + return err + } + + if credentials == nil { + return fmt.Errorf("please sign in using 'notehub auth signin' or 'notehub auth signin-token'") + } + + fmt.Printf("%s\n", credentials.Token) + return nil + }, +} + +func init() { + rootCmd.AddCommand(authCmd) + authCmd.AddCommand(signinCmd) + authCmd.AddCommand(signinTokenCmd) + authCmd.AddCommand(signoutCmd) + authCmd.AddCommand(tokenCmd) + + // Add --set-project flag to signin commands + signinCmd.Flags().StringVar(&flagSetProject, "set-project", "", "Automatically set project after signin (name or UID)") + signinTokenCmd.Flags().StringVar(&flagSetProject, "set-project", "", "Automatically set project after signin (name or UID)") +} + +// Banner for authentication +// http://patorjk.com/software/taag +// "Big" font +func banner() (s string) { + s += " _ _ _ \r\n" + s += " | | | | | | \r\n" + s += " _ __ ___ | |_ ___| |__ _ _| |__ \r\n" + s += "| '_ \\ / _ \\| __/ _ \\ '_ \\| | | | '_ \\ \r\n" + s += "| | | | (_) | || __/ | | | |_| | |_) | \r\n" + s += "|_| |_|\\___/ \\__\\___|_| |_|\\__,_|_.__/ \r\n" + s += "\r\n" + return +} + +// handleProjectSelection handles project selection after signin via flag or interactive prompt +func handleProjectSelection(projectFlag string) error { + // Check if a project is already set + currentProject := GetProject() + if currentProject != "" { + // Project already configured, no need to prompt + return nil + } + + // If project flag was provided, set it directly + if projectFlag != "" { + return setProjectByIdentifier(projectFlag) + } + + // Otherwise, offer interactive selection + return interactiveProjectSelection() +} + +// setProjectByIdentifier sets a project by name or UID (from project.go logic) +func setProjectByIdentifier(identifier string) error { + type Project struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProjectsResponse struct { + Projects []Project `json:"projects"` + } + + // First, try to use it directly as a UID + var selectedProject Project + url := fmt.Sprintf("/v1/projects/%s", identifier) + err := reqHubV1(false, GetAPIHub(), "GET", url, nil, &selectedProject) + + // If that failed or returned empty UID, it might be a project name + if err != nil || selectedProject.UID == "" { + projectsRsp := ProjectsResponse{} + err = reqHubV1(false, GetAPIHub(), "GET", "/v1/projects", nil, &projectsRsp) + if err != nil { + return fmt.Errorf("failed to list projects: %w", err) + } + + // Search for project by name + found := false + for _, project := range projectsRsp.Projects { + if project.Label == identifier { + selectedProject = project + found = true + break + } + } + + if !found { + return fmt.Errorf("project '%s' not found", identifier) + } + } + + // Save to config + viper.Set("project", selectedProject.UID) + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("\nActive project set to: %s\n", selectedProject.Label) + fmt.Printf("Project UID: %s\n\n", selectedProject.UID) + + return nil +} + +// interactiveProjectSelection prompts the user to select a project interactively +func interactiveProjectSelection() error { + type Project struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProjectsResponse struct { + Projects []Project `json:"projects"` + } + + // Fetch all projects + projectsRsp := ProjectsResponse{} + err := reqHubV1(false, GetAPIHub(), "GET", "/v1/projects", nil, &projectsRsp) + if err != nil { + // If we can't fetch projects, just show instructions + fmt.Println() + fmt.Println("To get started, you'll need to select a project to work with.") + fmt.Println("Run 'notehub project list' to see your available projects,") + fmt.Println("then 'notehub project set ' to select one.") + fmt.Println() + return nil + } + + if len(projectsRsp.Projects) == 0 { + fmt.Println() + fmt.Println("No projects found. You can create a new project at https://notehub.io") + fmt.Println() + return nil + } + + // Display projects with numbers + fmt.Println() + fmt.Println("Select a project to work with:") + fmt.Println() + for i, project := range projectsRsp.Projects { + fmt.Printf(" %d) %s\n", i+1, project.Label) + } + fmt.Println() + fmt.Printf("Enter project number (1-%d), or press Enter to skip: ", len(projectsRsp.Projects)) + + // Read user input + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return nil // Skip on error + } + + input = strings.TrimSpace(input) + if input == "" { + // User pressed Enter, skip selection + fmt.Println() + fmt.Println("Skipped project selection. You can set a project later with 'notehub project set '") + fmt.Println() + return nil + } + + // Parse selection + selection, err := strconv.Atoi(input) + if err != nil || selection < 1 || selection > len(projectsRsp.Projects) { + fmt.Println() + fmt.Printf("Invalid selection. You can set a project later with 'notehub project set '\n") + fmt.Println() + return nil + } + + // Set the selected project + selectedProject := projectsRsp.Projects[selection-1] + viper.Set("project", selectedProject.UID) + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println() + fmt.Printf("Active project set to: %s\n", selectedProject.Label) + fmt.Printf("Project UID: %s\n\n", selectedProject.UID) + + return nil +} diff --git a/notehub/cmd/config.go b/notehub/cmd/config.go new file mode 100644 index 0000000..cfcc6c9 --- /dev/null +++ b/notehub/cmd/config.go @@ -0,0 +1,324 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Credentials represent Notehub authentication credentials +type Credentials struct { + User string `json:"user,omitempty" mapstructure:"user"` + Token string `json:"token,omitempty" mapstructure:"token"` + ExpiresAt *time.Time `json:"expires_at,omitempty" mapstructure:"expires_at"` + Hub string `json:"-" mapstructure:"-"` +} + +// IsOAuthAccessToken checks if the token is an OAuth access token (vs PAT) +func (creds Credentials) IsOAuthAccessToken() bool { + personalAccessTokenPrefixes := []string{"ory_st_", "api_key_"} + for _, prefix := range personalAccessTokenPrefixes { + if strings.HasPrefix(creds.Token, prefix) { + return false + } + } + return true +} + +// AddHttpAuthHeader adds the authorization header to an HTTP request +func (creds Credentials) AddHttpAuthHeader(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+creds.Token) +} + +// IntrospectToken validates a token and returns the associated email +func IntrospectToken(hub string, token string) (string, error) { + if !strings.HasPrefix(hub, "api.") { + hub = "api." + hub + } + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", hub), nil) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + userinfo := map[string]interface{}{} + if err := note.JSONUnmarshal(body, &userinfo); err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + err := userinfo["err"] + return "", fmt.Errorf("%s (http %d)", err, resp.StatusCode) + } + + if email, ok := userinfo["email"].(string); !ok || email == "" { + fmt.Printf("response: %s\n", userinfo) + return "", fmt.Errorf("error introspecting token: no email in response") + } else { + return email, nil + } +} + +// Validate checks if credentials are valid +func (creds *Credentials) Validate() error { + if creds == nil { + return errors.New("no credentials specified") + } + _, err := IntrospectToken(creds.Hub, creds.Token) + return err +} + +// GetHub returns the currently configured Notehub hub +func GetHub() string { + hub := viper.GetString("hub") + if hub == "" { + hub = "notehub.io" // default + } + return hub +} + +// SetHub sets the Notehub hub +func SetHub(hub string) { + viper.Set("hub", hub) +} + +// GetCredentials returns credentials for the current hub +func GetHubCredentials() (*Credentials, error) { + hub := GetHub() + + // Viper treats dots in keys as nested paths, so "notehub.io" becomes "notehub.io" + // We need to access it using the dot notation that Viper creates + credsMap := viper.GetStringMap(fmt.Sprintf("credentials.%s", hub)) + if len(credsMap) == 0 { + return nil, nil + } + + creds := &Credentials{ + Hub: hub, + } + + if user, ok := credsMap["user"].(string); ok { + creds.User = user + } + if token, ok := credsMap["token"].(string); ok { + creds.Token = token + } + if expiresAt, ok := credsMap["expires_at"].(string); ok { + if t, err := time.Parse(time.RFC3339, expiresAt); err == nil { + creds.ExpiresAt = &t + } + } + + if creds.User == "" || creds.Token == "" { + return nil, nil + } + + return creds, nil +} + +// SetCredentials sets credentials for the current hub +func SetHubCredentials(token, user string, expiresAt *time.Time) error { + hub := GetHub() + + // Viper treats dots as path separators, so we use dot notation to set nested values + // For "notehub.io", this creates credentials.notehub.io structure + viper.Set(fmt.Sprintf("credentials.%s.user", hub), user) + viper.Set(fmt.Sprintf("credentials.%s.token", hub), token) + if expiresAt != nil { + viper.Set(fmt.Sprintf("credentials.%s.expires_at", hub), expiresAt.Format(time.RFC3339)) + } else { + viper.Set(fmt.Sprintf("credentials.%s.expires_at", hub), nil) + } + + return SaveConfig() +} + +// RemoveCredentials removes credentials for the current hub +func RemoveHubCredentials() error { + hub := GetHub() + + credentials, err := GetHubCredentials() + if err != nil { + return err + } + if credentials == nil { + return fmt.Errorf("not signed in to %s", hub) + } + + // If OAuth access token, revoke it + if credentials.IsOAuthAccessToken() { + // Revoke token logic would go here if needed + // For now, we just remove it from config + } + + // Remove credentials by clearing each field explicitly + viper.Set(fmt.Sprintf("credentials.%s.user", hub), "") + viper.Set(fmt.Sprintf("credentials.%s.token", hub), "") + viper.Set(fmt.Sprintf("credentials.%s.expires_at", hub), "") + + return SaveConfig() +} + +// SaveConfig writes the current viper configuration to disk +func SaveConfig() error { + configPath := getConfigPath() + + // Ensure the config directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Write the config file + if err := viper.WriteConfigAs(configPath); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +// getConfigPath returns the path to the config file +func getConfigPath() string { + // Use the same config directory as lib/config.go + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".", ".notehub", "config.yaml") + } + return filepath.Join(home, ".notehub", "config.yaml") +} + +// GetAPIHub returns the API hub URL +func GetAPIHub() string { + hub := GetHub() + if !strings.HasPrefix(hub, "api.") { + hub = "api." + hub + } + return hub +} + +// AddAuthenticationHeader adds authentication header to an HTTP request +func AddAuthenticationHeader(httpReq *http.Request) error { + credentials, err := GetHubCredentials() + if err != nil { + return err + } + + if credentials == nil { + hub := GetHub() + return fmt.Errorf("not authenticated to %s: please use 'notehub auth signin' to sign into the Notehub service", hub) + } + + // Set the header + httpReq.Header.Set("Authorization", "Bearer "+credentials.Token) + + return nil +} + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Display current configuration", + Long: `Display the current configuration including hub, credentials, and flag values.`, + Run: func(cmd *cobra.Command, args []string) { + displayConfig() + }, +} + +func init() { + rootCmd.AddCommand(configCmd) +} + +// displayConfig prints the current configuration in a readable format +func displayConfig() { + fmt.Println("\nCurrent Configuration:") + fmt.Println("=====================") + + // Display hub + hub := GetHub() + fmt.Printf("\nHub: %s\n", hub) + + // Display credentials + credentials, _ := GetHubCredentials() + if credentials != nil && credentials.User != "" { + fmt.Println("\nCredentials:") + fmt.Printf(" %s:\n", hub) + fmt.Printf(" User: %s\n", credentials.User) + + // Determine token type + tokenType := "OAuth" + if !credentials.IsOAuthAccessToken() { + tokenType = "Personal Access Token" + } + + // Check expiration + expires := "" + if credentials.ExpiresAt != nil { + if credentials.ExpiresAt.Before(time.Now()) { + expires = " [EXPIRED]" + } else { + expires = fmt.Sprintf(" (expires: %s)", credentials.ExpiresAt.Format("2006-01-02 15:04")) + } + } + + fmt.Printf(" Type: %s%s\n", tokenType, expires) + } else { + fmt.Println("\nCredentials: None (not signed in)") + } + + // Display active flag values (only non-empty ones) + fmt.Println("\nActive Settings:") + + settings := []struct { + name string + value string + }{ + {"project", viper.GetString("project")}, + {"product", viper.GetString("product")}, + {"device", viper.GetString("device")}, + } + + hasSettings := false + for _, setting := range settings { + if setting.value != "" { + fmt.Printf(" %s: %s\n", setting.name, setting.value) + hasSettings = true + } + } + + if !hasSettings { + fmt.Println(" (none)") + } + + // Display config file location + configFile := viper.ConfigFileUsed() + if configFile == "" { + configFile = getConfigPath() + } + fmt.Printf("\nConfig file: %s\n\n", configFile) +} diff --git a/notehub/cmd/device.go b/notehub/cmd/device.go new file mode 100644 index 0000000..fc88b4a --- /dev/null +++ b/notehub/cmd/device.go @@ -0,0 +1,685 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +// deviceCmd represents the device command +var deviceCmd = &cobra.Command{ + Use: "device", + Short: "Manage Notehub devices", + Long: `Commands for listing and managing devices in Notehub projects.`, +} + +// deviceListCmd represents the device list command +var deviceListCmd = &cobra.Command{ + Use: "list", + Short: "List all devices", + Long: `List all devices in the current project or a specified project.`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define device types + type Device struct { + UID string `json:"uid"` + SerialNumber string `json:"serial_number"` + ProductUID string `json:"product_uid,omitempty"` + FleetUIDs []string `json:"fleet_uids,omitempty"` + LastActivity time.Time `json:"last_activity,omitempty"` + ContactedAt time.Time `json:"contacted,omitempty"` + Provisioned time.Time `json:"provisioned,omitempty"` + LocationName string `json:"tower_location_name,omitempty"` + LocationWhen time.Time `json:"tower_when,omitempty"` + DeviceType string `json:"sku,omitempty"` + NotecardVersion string `json:"notecard_firmware_version,omitempty"` + HostVersion string `json:"host_firmware_version,omitempty"` + } + + type DevicesResponse struct { + Devices []Device `json:"devices"` + HasMore bool `json:"has_more"` + TotalCount int `json:"total_count,omitempty"` + } + + // Get devices using V1 API: GET /v1/projects/{projectUID}/devices + devicesRsp := DevicesResponse{} + url := fmt.Sprintf("/v1/projects/%s/devices", projectUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &devicesRsp) + if err != nil { + return fmt.Errorf("failed to list devices: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(devicesRsp, "", " ") + } else { + output, err = note.JSONMarshal(devicesRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(devicesRsp.Devices) == 0 { + fmt.Println("No devices found in this project.") + return nil + } + + // Display devices in human-readable format + fmt.Printf("\nDevices in Project:\n") + fmt.Printf("===================\n\n") + + for _, device := range devicesRsp.Devices { + fmt.Printf("Device: %s\n", device.UID) + if device.SerialNumber != "" { + fmt.Printf(" Serial Number: %s\n", device.SerialNumber) + } + if device.ProductUID != "" { + fmt.Printf(" Product: %s\n", device.ProductUID) + } + if device.DeviceType != "" { + fmt.Printf(" Type: %s\n", device.DeviceType) + } + if device.NotecardVersion != "" { + fmt.Printf(" Notecard Firmware: %s\n", device.NotecardVersion) + } + if device.HostVersion != "" { + fmt.Printf(" Host Firmware: %s\n", device.HostVersion) + } + if !device.LastActivity.IsZero() { + fmt.Printf(" Last Activity: %s\n", device.LastActivity.Format("2006-01-02 15:04:05 MST")) + } + if !device.ContactedAt.IsZero() { + fmt.Printf(" Last Contact: %s\n", device.ContactedAt.Format("2006-01-02 15:04:05 MST")) + } + if !device.Provisioned.IsZero() { + fmt.Printf(" Provisioned: %s\n", device.Provisioned.Format("2006-01-02 15:04:05 MST")) + } + if device.LocationName != "" { + fmt.Printf(" Location: %s\n", device.LocationName) + } + if len(device.FleetUIDs) > 0 { + fmt.Printf(" Fleets: %d\n", len(device.FleetUIDs)) + } + fmt.Println() + } + + fmt.Printf("Total devices: %d\n", len(devicesRsp.Devices)) + if devicesRsp.HasMore { + fmt.Println("(showing first page of results)") + } + fmt.Println() + + return nil + }, +} + +// deviceEnableCmd represents the device enable command +var deviceEnableCmd = &cobra.Command{ + Use: "enable [scope]", + Short: "Enable one or more devices", + Long: `Enable one or more devices in a Notehub project, allowing them to communicate with Notehub. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Examples: + # Enable a single device + notehub device enable dev:864475046552567 + + # Enable all devices in a fleet + notehub device enable @production + + # Enable all devices in project + notehub device enable @ + + # Enable devices from a file + notehub device enable @devices.txt`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + scope := args[0] + + verbose := GetVerbose() + appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices to enable") + } + + // Enable each device + for _, deviceUID := range scopeDevices { + url := fmt.Sprintf("/v1/projects/%s/devices/%s/enable", appMetadata.App.UID, deviceUID) + err := reqHubV1(verbose, GetAPIHub(), "POST", url, nil, nil) + if err != nil { + return fmt.Errorf("failed to enable device %s: %w", deviceUID, err) + } + if verbose { + fmt.Printf("Device %s enabled\n", deviceUID) + } + } + + fmt.Printf("Successfully enabled %d device(s)\n", len(scopeDevices)) + return nil + }, +} + +// deviceDisableCmd represents the device disable command +var deviceDisableCmd = &cobra.Command{ + Use: "disable [scope]", + Short: "Disable one or more devices", + Long: `Disable one or more devices in a Notehub project, preventing them from communicating with Notehub. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Examples: + # Disable a single device + notehub device disable dev:864475046552567 + + # Disable all devices in a fleet + notehub device disable @production + + # Disable all devices in project + notehub device disable @ + + # Disable devices from a file + notehub device disable @devices.txt`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + scope := args[0] + + verbose := GetVerbose() + appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices to disable") + } + + // Disable each device + for _, deviceUID := range scopeDevices { + url := fmt.Sprintf("/v1/projects/%s/devices/%s/disable", appMetadata.App.UID, deviceUID) + err := reqHubV1(verbose, GetAPIHub(), "POST", url, nil, nil) + if err != nil { + return fmt.Errorf("failed to disable device %s: %w", deviceUID, err) + } + if verbose { + fmt.Printf("Device %s disabled\n", deviceUID) + } + } + + fmt.Printf("Successfully disabled %d device(s)\n", len(scopeDevices)) + return nil + }, +} + +// deviceMoveCmd represents the device move command +var deviceMoveCmd = &cobra.Command{ + Use: "move [scope] [fleet-uid-or-name]", + Short: "Move devices to a fleet", + Long: `Move one or more devices to a fleet. If a device is not in any fleet, it will be assigned. +If a device is already in a fleet, it will be moved to the new fleet. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Examples: + # Move a single device to a fleet + notehub device move dev:864475046552567 production + + # Move a device to a fleet by UID + notehub device move dev:864475046552567 fleet:xxxx + + # Move all devices from one fleet to another + notehub device move @old-fleet new-fleet + + # Move devices from a file to a fleet + notehub device move @devices.txt production`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + scope := args[0] + targetFleetIdentifier := args[1] + + verbose := GetVerbose() + appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices to move") + } + + // Find the target fleet by UID or name + var targetFleetUID string + if strings.HasPrefix(targetFleetIdentifier, "fleet:") { + targetFleetUID = targetFleetIdentifier + } else { + // Search for fleet by name + found := false + for _, fleet := range appMetadata.Fleets { + if fleet.Name == targetFleetIdentifier { + targetFleetUID = fleet.UID + found = true + break + } + } + if !found { + return fmt.Errorf("fleet '%s' not found in project", targetFleetIdentifier) + } + } + + // Define request body types + type FleetRequest struct { + FleetUIDs []string `json:"fleet_uids"` + } + + type FleetResponse struct { + Fleets []struct { + UID string `json:"uid"` + Label string `json:"label"` + } `json:"fleets"` + } + + // Move each device to the target fleet + for _, deviceUID := range scopeDevices { + url := fmt.Sprintf("/v1/projects/%s/devices/%s/fleets", appMetadata.App.UID, deviceUID) + + // First, get the device's current fleets + var currentFleets FleetResponse + err := reqHubV1(verbose, GetAPIHub(), "GET", url, nil, ¤tFleets) + if err != nil { + return fmt.Errorf("failed to get current fleets for device %s: %w", deviceUID, err) + } + + // Remove device from all current fleets if it has any + if len(currentFleets.Fleets) > 0 { + currentFleetUIDs := make([]string, len(currentFleets.Fleets)) + for i, fleet := range currentFleets.Fleets { + currentFleetUIDs[i] = fleet.UID + } + removeBody := FleetRequest{FleetUIDs: currentFleetUIDs} + removeJSON, err := note.JSONMarshal(removeBody) + if err != nil { + return fmt.Errorf("failed to marshal remove request: %w", err) + } + err = reqHubV1(verbose, GetAPIHub(), "DELETE", url, removeJSON, nil) + if err != nil { + return fmt.Errorf("failed to remove device %s from current fleets: %w", deviceUID, err) + } + if verbose { + fmt.Printf("Device %s removed from %d fleet(s)\n", deviceUID, len(currentFleetUIDs)) + } + } + + // Add device to the target fleet + addBody := FleetRequest{FleetUIDs: []string{targetFleetUID}} + addJSON, err := note.JSONMarshal(addBody) + if err != nil { + return fmt.Errorf("failed to marshal add request: %w", err) + } + err = reqHubV1(verbose, GetAPIHub(), "PUT", url, addJSON, nil) + if err != nil { + return fmt.Errorf("failed to move device %s to fleet: %w", deviceUID, err) + } + if verbose { + fmt.Printf("Device %s moved to fleet %s\n", deviceUID, targetFleetUID) + } + } + + fmt.Printf("Successfully moved %d device(s) to fleet %s\n", len(scopeDevices), targetFleetUID) + return nil + }, +} + +// deviceHealthCmd represents the device health command +var deviceHealthCmd = &cobra.Command{ + Use: "health [device-uid]", + Short: "Get device health log", + Long: `Get the health log for a specific device, showing boot events, DFU completions, and other health-related information. + +Examples: + # Get health log for a device + notehub device health dev:864475046552567 + + # Get health log with JSON output + notehub device health dev:864475046552567 --json + + # Get health log with pretty JSON + notehub device health dev:864475046552567 --pretty`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + deviceUID := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define health log types + type HealthLogEntry struct { + When time.Time `json:"when"` + Alert bool `json:"alert"` + Text string `json:"text"` + } + + type HealthLogResponse struct { + HealthLog []HealthLogEntry `json:"health_log"` + } + + // Get device health log using V1 API: GET /v1/projects/{projectUID}/devices/{deviceUID}/health-log + healthLogRsp := HealthLogResponse{} + url := fmt.Sprintf("/v1/projects/%s/devices/%s/health-log", projectUID, deviceUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &healthLogRsp) + if err != nil { + return fmt.Errorf("failed to get device health log: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(healthLogRsp, "", " ") + } else { + output, err = note.JSONMarshal(healthLogRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(healthLogRsp.HealthLog) == 0 { + fmt.Println("No health log entries found for this device.") + return nil + } + + // Display health log in human-readable format + fmt.Printf("\nHealth Log for Device: %s\n", deviceUID) + fmt.Printf("================================\n\n") + + for _, entry := range healthLogRsp.HealthLog { + alertMarker := " " + if entry.Alert { + alertMarker = "!" + } + fmt.Printf("[%s] %s %s\n", entry.When.Format("2006-01-02 15:04:05 MST"), alertMarker, entry.Text) + } + + fmt.Printf("\nTotal entries: %d\n", len(healthLogRsp.HealthLog)) + fmt.Println() + + return nil + }, +} + +// deviceSessionCmd represents the device session command +var deviceSessionCmd = &cobra.Command{ + Use: "session [device-uid]", + Short: "Get device session log", + Long: `Get the session log for a specific device, showing connection history, network information, and session statistics. + +Examples: + # Get session log for a device + notehub device session dev:864475046552567 + + # Get session log with JSON output + notehub device session dev:864475046552567 --json + + # Get session log with pretty JSON + notehub device session dev:864475046552567 --pretty`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + deviceUID := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define session types + type Tower struct { + Time int64 `json:"time,omitempty"` + Name string `json:"n,omitempty"` + Country string `json:"c,omitempty"` + Lat float64 `json:"lat,omitempty"` + Lon float64 `json:"lon,omitempty"` + Zone string `json:"zone,omitempty"` + } + + type Period struct { + Since int64 `json:"since,omitempty"` + Duration int64 `json:"duration,omitempty"` + BytesRcvd int64 `json:"bytes_rcvd,omitempty"` + BytesSent int64 `json:"bytes_sent,omitempty"` + SessionsTLS int64 `json:"sessions_tls,omitempty"` + NotesSent int64 `json:"notes_sent,omitempty"` + } + + type Session struct { + SessionUID string `json:"session"` + Device string `json:"device,omitempty"` + Product string `json:"product,omitempty"` + Fleets []string `json:"fleets,omitempty"` + When int64 `json:"when,omitempty"` + SessionBegan int64 `json:"session_began,omitempty"` + SessionEnded int64 `json:"session_ended,omitempty"` + WhyOpened string `json:"why_session_opened,omitempty"` + WhyClosed string `json:"why_session_closed,omitempty"` + Cell string `json:"cell,omitempty"` + RSSI int `json:"rssi,omitempty"` + SINR int `json:"sinr,omitempty"` + RSRP int `json:"rsrp,omitempty"` + RSRQ int `json:"rsrq,omitempty"` + Bars int `json:"bars,omitempty"` + RAT string `json:"rat,omitempty"` + Bearer string `json:"bearer,omitempty"` + IP string `json:"ip,omitempty"` + ICCID string `json:"iccid,omitempty"` + APN string `json:"apn,omitempty"` + Tower *Tower `json:"tower,omitempty"` + Voltage float64 `json:"voltage,omitempty"` + Temp float64 `json:"temp,omitempty"` + Continuous bool `json:"continuous,omitempty"` + TLS bool `json:"tls,omitempty"` + Events int `json:"events,omitempty"` + Moved int64 `json:"moved,omitempty"` + Orientation string `json:"orientation,omitempty"` + Period *Period `json:"period,omitempty"` + } + + type SessionsResponse struct { + Sessions []Session `json:"sessions"` + HasMore bool `json:"has_more"` + } + + // Get device sessions using V1 API: GET /v1/projects/{projectUID}/devices/{deviceUID}/sessions + sessionsRsp := SessionsResponse{} + url := fmt.Sprintf("/v1/projects/%s/devices/%s/sessions", projectUID, deviceUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &sessionsRsp) + if err != nil { + return fmt.Errorf("failed to get device sessions: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(sessionsRsp, "", " ") + } else { + output, err = note.JSONMarshal(sessionsRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(sessionsRsp.Sessions) == 0 { + fmt.Println("No sessions found for this device.") + return nil + } + + // Display sessions in human-readable format + fmt.Printf("\nSession Log for Device: %s\n", deviceUID) + fmt.Printf("=================================\n\n") + + for i, session := range sessionsRsp.Sessions { + if i > 0 { + fmt.Println("---") + } + + // Session ID and timing + fmt.Printf("Session: %s\n", session.SessionUID) + if session.When > 0 { + sessionTime := time.Unix(session.When, 0) + fmt.Printf(" Time: %s\n", sessionTime.Format("2006-01-02 15:04:05 MST")) + } + + // Session status + if session.WhyOpened != "" { + fmt.Printf(" Opened: %s\n", session.WhyOpened) + } + if session.WhyClosed != "" { + fmt.Printf(" Closed: %s\n", session.WhyClosed) + } + + // Network information + if session.RAT != "" || session.Bearer != "" { + fmt.Printf(" Network: %s", session.RAT) + if session.Bearer != "" { + fmt.Printf(" (%s)", session.Bearer) + } + fmt.Println() + } + + // Signal quality + if session.Bars > 0 { + fmt.Printf(" Signal: %d bars", session.Bars) + if session.RSSI != 0 { + fmt.Printf(" (RSSI: %d)", session.RSSI) + } + fmt.Println() + } + + // Location + if session.Tower != nil && session.Tower.Name != "" { + fmt.Printf(" Location: %s", session.Tower.Name) + if session.Tower.Country != "" { + fmt.Printf(", %s", session.Tower.Country) + } + fmt.Println() + } + + // Device status + if session.Voltage > 0 { + fmt.Printf(" Voltage: %.3fV", session.Voltage) + if session.Temp > 0 { + fmt.Printf(", Temp: %.1f°C", session.Temp) + } + fmt.Println() + } + + // Session stats + if session.Events > 0 { + fmt.Printf(" Events: %d", session.Events) + if session.TLS { + fmt.Printf(" (TLS)") + } + fmt.Println() + } + + // Data transfer + if session.Period != nil { + if session.Period.BytesSent > 0 || session.Period.BytesRcvd > 0 { + fmt.Printf(" Data: sent %d bytes, received %d bytes", session.Period.BytesSent, session.Period.BytesRcvd) + if session.Period.Duration > 0 { + fmt.Printf(" (duration: %ds)", session.Period.Duration) + } + fmt.Println() + } + } + } + + fmt.Printf("\nTotal sessions: %d", len(sessionsRsp.Sessions)) + if sessionsRsp.HasMore { + fmt.Printf(" (showing first page)") + } + fmt.Println() + fmt.Println() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(deviceCmd) + deviceCmd.AddCommand(deviceListCmd) + deviceCmd.AddCommand(deviceEnableCmd) + deviceCmd.AddCommand(deviceDisableCmd) + deviceCmd.AddCommand(deviceMoveCmd) + deviceCmd.AddCommand(deviceHealthCmd) + deviceCmd.AddCommand(deviceSessionCmd) +} diff --git a/notehub/cmd/dfu.go b/notehub/cmd/dfu.go new file mode 100644 index 0000000..009dd16 --- /dev/null +++ b/notehub/cmd/dfu.go @@ -0,0 +1,627 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "strings" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +// dfuCmd represents the dfu command +var dfuCmd = &cobra.Command{ + Use: "dfu", + Short: "Manage device firmware updates", + Long: `Commands for scheduling and managing firmware updates for Notecards and host MCUs.`, +} + +// dfuUpdateCmd represents the dfu update command +var dfuUpdateCmd = &cobra.Command{ + Use: "update [firmware-type] [filename] [scope]", + Short: "Schedule a firmware update", + Long: `Schedule a firmware update for devices. Firmware type must be either 'host' or 'notecard'. + +The filename should match a firmware file that has been uploaded to your Notehub project. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Additional filters can be used to narrow down the scope: + --location Filter by location + --notecard-firmware Filter by Notecard firmware version + --host-firmware Filter by host firmware version + --product Filter by product UID + --sku Filter by SKU + --tag Filter by device tags (comma-separated) + --serial Filter by serial numbers (comma-separated) + +Examples: + # Schedule notecard firmware update for a specific device + notehub dfu update notecard notecard-6.2.1.bin dev:864475046552567 + + # Schedule host firmware update for all devices in a fleet + notehub dfu update host app-v1.2.3.bin @production + + # Schedule update for multiple devices + notehub dfu update notecard notecard-6.2.1.bin dev:aaa,dev:bbb,dev:ccc + + # Schedule update for all devices in project + notehub dfu update notecard notecard-6.2.1.bin @ + + # Schedule update for devices from a file + notehub dfu update host app-v1.2.3.bin @devices.txt + + # Schedule update with additional filters + notehub dfu update notecard notecard-6.2.1.bin @production --sku NOTE-WBEX`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + firmwareType := args[0] + filename := args[1] + scope := args[2] + + // Validate firmware type + if firmwareType != "host" && firmwareType != "notecard" { + return fmt.Errorf("firmware type must be 'host' or 'notecard', got '%s'", firmwareType) + } + + verbose := GetVerbose() + + // Resolve scope to device UIDs + appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices found in scope '%s'", scope) + } + + // Get additional filter flags + tags, _ := cmd.Flags().GetString("tag") + serialNumbers, _ := cmd.Flags().GetString("serial") + location, _ := cmd.Flags().GetString("location") + notecardFirmware, _ := cmd.Flags().GetString("notecard-firmware") + hostFirmware, _ := cmd.Flags().GetString("host-firmware") + productUID, _ := cmd.Flags().GetString("product") + sku, _ := cmd.Flags().GetString("sku") + + // Build request body + type DfuActionRequest struct { + Filename string `json:"filename"` + } + + reqBody := DfuActionRequest{ + Filename: filename, + } + + // Marshal request to JSON + reqJSON, err := note.JSONMarshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Build URL with query parameters + url := fmt.Sprintf("/v1/projects/%s/dfu/%s/update", appMetadata.App.UID, firmwareType) + + // Convert resolved device UIDs to comma-separated query parameter + deviceUIDs := strings.Join(scopeDevices, ",") + + // Add query parameters + firstParam := true + if deviceUIDs != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "deviceUID=" + deviceUIDs + } + if tags != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "tag=" + tags + } + if serialNumbers != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "serialNumber=" + serialNumbers + } + if location != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "location=" + location + } + if notecardFirmware != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "notecardFirmware=" + notecardFirmware + } + if hostFirmware != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "hostFirmware=" + hostFirmware + } + if productUID != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "productUID=" + productUID + } + if sku != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "sku=" + sku + } + + // Schedule firmware update using V1 API: POST /v1/projects/{projectUID}/dfu/{firmwareType}/update + err = reqHubV1(verbose, GetAPIHub(), "POST", url, reqJSON, nil) + if err != nil { + return fmt.Errorf("failed to schedule firmware update: %w", err) + } + + fmt.Printf("\nFirmware update scheduled successfully!\n\n") + fmt.Printf("Firmware Type: %s\n", firmwareType) + fmt.Printf("Filename: %s\n", filename) + fmt.Printf("Scope: %s\n", scope) + fmt.Printf("Target Devices: %d device(s)\n", len(scopeDevices)) + if verbose { + fmt.Printf("Device UIDs: %s\n", deviceUIDs) + } + if tags != "" { + fmt.Printf("Additional Tag Filter: %s\n", tags) + } + if serialNumbers != "" { + fmt.Printf("Additional Serial Filter: %s\n", serialNumbers) + } + if location != "" { + fmt.Printf("Additional Location Filter: %s\n", location) + } + if sku != "" { + fmt.Printf("Additional SKU Filter: %s\n", sku) + } + fmt.Println() + + return nil + }, +} + +// dfuCancelCmd represents the dfu cancel command +var dfuCancelCmd = &cobra.Command{ + Use: "cancel [firmware-type] [scope]", + Short: "Cancel pending firmware updates", + Long: `Cancel pending firmware updates for devices. Firmware type must be either 'host' or 'notecard'. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Additional filters can be used to narrow down the scope: + --tag Filter by device tags (comma-separated) + --serial Filter by serial numbers (comma-separated) + +Examples: + # Cancel notecard firmware update for a specific device + notehub dfu cancel notecard dev:864475046552567 + + # Cancel host firmware updates for all devices in a fleet + notehub dfu cancel host @production + + # Cancel updates for multiple devices + notehub dfu cancel notecard dev:aaa,dev:bbb,dev:ccc + + # Cancel updates for all devices in project + notehub dfu cancel notecard @ + + # Cancel updates for devices from a file + notehub dfu cancel host @devices.txt`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + firmwareType := args[0] + scope := args[1] + + // Validate firmware type + if firmwareType != "host" && firmwareType != "notecard" { + return fmt.Errorf("firmware type must be 'host' or 'notecard', got '%s'", firmwareType) + } + + verbose := GetVerbose() + + // Resolve scope to device UIDs + appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices found in scope '%s'", scope) + } + + // Get additional filter flags + tags, _ := cmd.Flags().GetString("tag") + serialNumbers, _ := cmd.Flags().GetString("serial") + + // Build URL with query parameters + url := fmt.Sprintf("/v1/projects/%s/dfu/%s/cancel", appMetadata.App.UID, firmwareType) + + // Convert resolved device UIDs to comma-separated query parameter + deviceUIDs := strings.Join(scopeDevices, ",") + + // Add query parameters + firstParam := true + if deviceUIDs != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "deviceUID=" + deviceUIDs + } + if tags != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "tag=" + tags + } + if serialNumbers != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "serialNumber=" + serialNumbers + } + + // Cancel firmware update using V1 API: POST /v1/projects/{projectUID}/dfu/{firmwareType}/cancel + err = reqHubV1(verbose, GetAPIHub(), "POST", url, nil, nil) + if err != nil { + return fmt.Errorf("failed to cancel firmware update: %w", err) + } + + fmt.Printf("\nFirmware update cancelled successfully!\n\n") + fmt.Printf("Firmware Type: %s\n", firmwareType) + fmt.Printf("Scope: %s\n", scope) + fmt.Printf("Target Devices: %d device(s)\n", len(scopeDevices)) + if verbose { + fmt.Printf("Device UIDs: %s\n", deviceUIDs) + } + if tags != "" { + fmt.Printf("Additional Tag Filter: %s\n", tags) + } + if serialNumbers != "" { + fmt.Printf("Additional Serial Filter: %s\n", serialNumbers) + } + fmt.Println() + + return nil + }, +} + +// dfuListCmd represents the dfu list command +var dfuListCmd = &cobra.Command{ + Use: "list", + Short: "List available firmware files", + Long: `List all firmware files available in the current project. + +You can filter by firmware type (host or notecard) and other criteria. + +Examples: + # List all firmware files + notehub dfu list + + # List only host firmware + notehub dfu list --type host + + # List only notecard firmware + notehub dfu list --type notecard + + # List with JSON output + notehub dfu list --pretty`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Get filter flags + firmwareType, _ := cmd.Flags().GetString("type") + productUID, _ := cmd.Flags().GetString("product") + version, _ := cmd.Flags().GetString("version") + target, _ := cmd.Flags().GetString("target") + filename, _ := cmd.Flags().GetString("filename") + unpublished, _ := cmd.Flags().GetBool("unpublished") + + // Define firmware info types + type FirmwareInfo struct { + Filename string `json:"filename"` + Version string `json:"version,omitempty"` + MD5 string `json:"md5,omitempty"` + Organization string `json:"organization,omitempty"` + Built string `json:"built,omitempty"` + Product string `json:"product,omitempty"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` + Created string `json:"created,omitempty"` + Target string `json:"target,omitempty"` + Published bool `json:"published,omitempty"` + } + + // Build URL with query parameters + url := fmt.Sprintf("/v1/projects/%s/firmware", projectUID) + + // Add query parameters + firstParam := true + if firmwareType != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "firmwareType=" + firmwareType + } + if productUID != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "product=" + productUID + } + if version != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "version=" + version + } + if target != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "target=" + target + } + if filename != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "filename=" + filename + } + if unpublished { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += "unpublished=true" + } + + // Get firmware list using V1 API: GET /v1/projects/{projectUID}/firmware + var firmwareList []FirmwareInfo + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &firmwareList) + if err != nil { + return fmt.Errorf("failed to list firmware: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(firmwareList, "", " ") + } else { + output, err = note.JSONMarshal(firmwareList) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(firmwareList) == 0 { + fmt.Println("No firmware files found.") + return nil + } + + // Display firmware in human-readable format + fmt.Printf("\nAvailable Firmware Files:\n") + fmt.Printf("=========================\n\n") + + // Group by type + hostFirmware := []FirmwareInfo{} + notecardFirmware := []FirmwareInfo{} + otherFirmware := []FirmwareInfo{} + + for _, fw := range firmwareList { + if fw.Type == "host" { + hostFirmware = append(hostFirmware, fw) + } else if fw.Type == "notecard" { + notecardFirmware = append(notecardFirmware, fw) + } else { + otherFirmware = append(otherFirmware, fw) + } + } + + // Display host firmware + if len(hostFirmware) > 0 { + fmt.Printf("Host Firmware (%d):\n", len(hostFirmware)) + fmt.Printf("------------------\n") + for _, fw := range hostFirmware { + fmt.Printf(" %s", fw.Filename) + if fw.Version != "" { + fmt.Printf(" (v%s)", fw.Version) + } + if !fw.Published { + fmt.Printf(" [unpublished]") + } + fmt.Println() + if fw.Description != "" { + fmt.Printf(" Description: %s\n", fw.Description) + } + if fw.Built != "" { + fmt.Printf(" Built: %s\n", fw.Built) + } + if fw.Target != "" { + fmt.Printf(" Target: %s\n", fw.Target) + } + fmt.Println() + } + } + + // Display notecard firmware + if len(notecardFirmware) > 0 { + fmt.Printf("Notecard Firmware (%d):\n", len(notecardFirmware)) + fmt.Printf("----------------------\n") + for _, fw := range notecardFirmware { + fmt.Printf(" %s", fw.Filename) + if fw.Version != "" { + fmt.Printf(" (v%s)", fw.Version) + } + if !fw.Published { + fmt.Printf(" [unpublished]") + } + fmt.Println() + if fw.Description != "" { + fmt.Printf(" Description: %s\n", fw.Description) + } + if fw.Built != "" { + fmt.Printf(" Built: %s\n", fw.Built) + } + if fw.Target != "" { + fmt.Printf(" Target: %s\n", fw.Target) + } + fmt.Println() + } + } + + // Display other firmware + if len(otherFirmware) > 0 { + fmt.Printf("Other Firmware (%d):\n", len(otherFirmware)) + fmt.Printf("-------------------\n") + for _, fw := range otherFirmware { + fmt.Printf(" %s", fw.Filename) + if fw.Version != "" { + fmt.Printf(" (v%s)", fw.Version) + } + if fw.Type != "" { + fmt.Printf(" [%s]", fw.Type) + } + if !fw.Published { + fmt.Printf(" [unpublished]") + } + fmt.Println() + if fw.Description != "" { + fmt.Printf(" Description: %s\n", fw.Description) + } + if fw.Built != "" { + fmt.Printf(" Built: %s\n", fw.Built) + } + if fw.Target != "" { + fmt.Printf(" Target: %s\n", fw.Target) + } + fmt.Println() + } + } + + fmt.Printf("Total firmware files: %d\n\n", len(firmwareList)) + + return nil + }, +} + +func init() { + rootCmd.AddCommand(dfuCmd) + dfuCmd.AddCommand(dfuListCmd) + dfuCmd.AddCommand(dfuUpdateCmd) + dfuCmd.AddCommand(dfuCancelCmd) + + // Add flags for dfu list + dfuListCmd.Flags().String("type", "", "Filter by firmware type (host or notecard)") + dfuListCmd.Flags().String("product", "", "Filter by product UID") + dfuListCmd.Flags().String("version", "", "Filter by version") + dfuListCmd.Flags().String("target", "", "Filter by target device") + dfuListCmd.Flags().String("filename", "", "Filter by filename") + dfuListCmd.Flags().Bool("unpublished", false, "Include unpublished firmware") + dfuListCmd.Flags().MarkHidden("unpublished") + + // Add flags for dfu update (additional filters beyond scope) + dfuUpdateCmd.Flags().String("tag", "", "Additional filter by device tags (comma-separated)") + dfuUpdateCmd.Flags().String("serial", "", "Additional filter by serial numbers (comma-separated)") + dfuUpdateCmd.Flags().String("location", "", "Additional filter by location") + dfuUpdateCmd.Flags().String("notecard-firmware", "", "Additional filter by Notecard firmware version") + dfuUpdateCmd.Flags().String("host-firmware", "", "Additional filter by host firmware version") + dfuUpdateCmd.Flags().String("product", "", "Additional filter by product UID") + dfuUpdateCmd.Flags().String("sku", "", "Additional filter by SKU") + + // Add flags for dfu cancel (additional filters beyond scope) + dfuCancelCmd.Flags().String("tag", "", "Additional filter by device tags (comma-separated)") + dfuCancelCmd.Flags().String("serial", "", "Additional filter by serial numbers (comma-separated)") +} diff --git a/notehub/cmd/explore.go b/notehub/cmd/explore.go new file mode 100644 index 0000000..2446347 --- /dev/null +++ b/notehub/cmd/explore.go @@ -0,0 +1,159 @@ +// Copyright 2021 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "sort" + + "github.com/blues/note-go/note" + "github.com/blues/note-go/notecard" + "github.com/blues/note-go/notehub" + "github.com/spf13/cobra" +) + +var ( + flagReserved bool +) + +// exploreCmd represents the explore command +var exploreCmd = &cobra.Command{ + Use: "explore", + Short: "Explore the contents of a device", + Long: `Explore the notefiles and notes on a device. + +By default, reserved notefiles are not shown. Use --reserved to include them. + +Example: + notehub explore --device dev:xxxx --pretty + notehub explore --scope @production --reserved`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + device := GetDevice() + if flagScope == "" && device == "" { + return fmt.Errorf("use --device to specify a device or --scope to specify multiple devices") + } + + // If scope is specified, iterate over multiple devices + if flagScope != "" { + verbose := GetVerbose() + pretty := GetPretty() + appMetadata, scopeDevices, _, err := appGetScope(flagScope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices found within the specified scope") + } + + for _, deviceUID := range scopeDevices { + reqFlagDevice = deviceUID + err = exploreDevice(flagReserved, verbose, pretty) + if err != nil { + return err + } + } + + // Set the project for the request + reqFlagApp = appMetadata.App.UID + } else { + // Single device exploration + reqFlagDevice = device + reqFlagApp = GetProject() + err := exploreDevice(flagReserved, GetVerbose(), GetPretty()) + if err != nil { + return err + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(exploreCmd) + + exploreCmd.Flags().BoolVarP(&flagReserved, "reserved", "r", false, "Include reserved notefiles") + exploreCmd.Flags().StringVarP(&flagScope, "scope", "s", "", "Device scope (alternative to --device)") +} + +// Explore the contents of a device +// Note: This function intentionally uses V0 Notecard APIs (file.changes, note.changes) +// These are device-specific APIs for communicating with Notecard hardware, distinct from +// the Notehub project management APIs which have been migrated to V1 REST endpoints. +func exploreDevice(includeReserved bool, verbose bool, pretty bool) (err error) { + // Get the list of notefiles using file.changes API + req := notehub.HubRequest{} + req.Req = notecard.ReqFileChanges + req.Allow = includeReserved + var rsp notehub.HubRequest + rsp, err = hubTransactionRequest(req, verbose) + if err != nil { + return + } + + // Exit if no notefiles + fmt.Printf("%s\n", reqFlagDevice) + if rsp.FileInfo == nil || len(*rsp.FileInfo) == 0 { + fmt.Printf(" no notefiles\n") + return + } + + // Sort the notefiles + notefileIDs := []string{} + for notefileID := range *rsp.FileInfo { + notefileIDs = append(notefileIDs, notefileID) + } + sort.Strings(notefileIDs) + + // Iterate over each file + for _, notefileID := range notefileIDs { + fmt.Printf(" %s\n", notefileID) + + // Get the notes using note.changes API + req = notehub.HubRequest{} + req.Req = notecard.ReqNoteChanges + req.Allow = includeReserved + req.Deleted = true + req.NotefileID = notefileID + rsp, err = hubTransactionRequest(req, verbose) + if err != nil { + return + } + + // Exit if no notefiles + if rsp.Notes == nil || len(*rsp.Notes) == 0 { + continue + } + + // Show the notes + for noteID, n := range *rsp.Notes { + fmt.Printf(" %s", noteID) + if n.Deleted { + fmt.Printf(" (DELETED)") + } + fmt.Printf("\n") + if n.Body != nil { + prefix := " " + var bodyJSON []byte + if pretty { + bodyJSON, err = note.JSONMarshalIndent(*n.Body, prefix, " ") + } else { + bodyJSON, err = note.JSONMarshal(*n.Body) + } + if err == nil { + fmt.Printf("%s%s\n", prefix, string(bodyJSON)) + } + } + if n.Payload != nil { + fmt.Printf(" Payload: %d bytes\n", len(*n.Payload)) + } + } + } + + return +} diff --git a/notehub/cmd/fleet.go b/notehub/cmd/fleet.go new file mode 100644 index 0000000..647d9e3 --- /dev/null +++ b/notehub/cmd/fleet.go @@ -0,0 +1,615 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "time" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +// fleetCmd represents the fleet command +var fleetCmd = &cobra.Command{ + Use: "fleet", + Short: "Manage Notehub fleets", + Long: `Commands for listing and managing fleets in Notehub projects.`, +} + +// fleetListCmd represents the fleet list command +var fleetListCmd = &cobra.Command{ + Use: "list", + Short: "List all fleets", + Long: `List all fleets in the current project or a specified project.`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define fleet types + type ConnectivityAssurance struct { + Enabled bool `json:"enabled"` + } + + type Fleet struct { + UID string `json:"uid"` + Label string `json:"label"` + Created time.Time `json:"created,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins int `json:"watchdog_mins,omitempty"` + } + + type FleetsResponse struct { + Fleets []Fleet `json:"fleets"` + } + + // Get fleets using V1 API: GET /v1/projects/{projectUID}/fleets + fleetsRsp := FleetsResponse{} + url := fmt.Sprintf("/v1/projects/%s/fleets", projectUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &fleetsRsp) + if err != nil { + return fmt.Errorf("failed to list fleets: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(fleetsRsp, "", " ") + } else { + output, err = note.JSONMarshal(fleetsRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(fleetsRsp.Fleets) == 0 { + fmt.Println("No fleets found in this project.") + return nil + } + + // Display fleets in human-readable format + fmt.Printf("\nFleets in Project:\n") + fmt.Printf("==================\n\n") + + for _, fleet := range fleetsRsp.Fleets { + fmt.Printf("Fleet: %s\n", fleet.Label) + fmt.Printf(" UID: %s\n", fleet.UID) + if !fleet.Created.IsZero() { + fmt.Printf(" Created: %s\n", fleet.Created.Format("2006-01-02 15:04:05 MST")) + } + if fleet.SmartRule != "" { + fmt.Printf(" Smart Rule: %s\n", fleet.SmartRule) + } + if fleet.ConnectivityAssurance != nil { + status := "disabled" + if fleet.ConnectivityAssurance.Enabled { + status = "enabled" + } + fmt.Printf(" Connectivity Assurance: %s\n", status) + } + if fleet.WatchdogMins > 0 { + fmt.Printf(" Watchdog: %d minutes\n", fleet.WatchdogMins) + } + fmt.Println() + } + + fmt.Printf("Total fleets: %d\n\n", len(fleetsRsp.Fleets)) + + return nil + }, +} + +// fleetGetCmd represents the fleet get command +var fleetGetCmd = &cobra.Command{ + Use: "get [fleet-uid-or-name]", + Short: "Get details about a specific fleet", + Long: `Get detailed information about a specific fleet by UID or name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + fleetIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define fleet types + type ConnectivityAssurance struct { + Enabled bool `json:"enabled"` + } + + type Fleet struct { + UID string `json:"uid"` + Label string `json:"label"` + Created time.Time `json:"created,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins int `json:"watchdog_mins,omitempty"` + } + + type FleetsResponse struct { + Fleets []Fleet `json:"fleets"` + } + + // First, try to use it directly as a UID + var selectedFleet Fleet + url := fmt.Sprintf("/v1/projects/%s/fleets/%s", projectUID, fleetIdentifier) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &selectedFleet) + + // If that failed or returned empty UID, it might be a fleet name - fetch all fleets and search + if err != nil || selectedFleet.UID == "" { + fleetsRsp := FleetsResponse{} + listURL := fmt.Sprintf("/v1/projects/%s/fleets", projectUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "GET", listURL, nil, &fleetsRsp) + if err != nil { + return fmt.Errorf("failed to list fleets: %w", err) + } + + // Search for fleet by name (exact match) + found := false + for _, fleet := range fleetsRsp.Fleets { + if fleet.Label == fleetIdentifier { + selectedFleet = fleet + found = true + break + } + } + + if !found { + return fmt.Errorf("fleet '%s' not found in project", fleetIdentifier) + } + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(selectedFleet, "", " ") + } else { + output, err = note.JSONMarshal(selectedFleet) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display fleet in human-readable format + fmt.Printf("\nFleet Details:\n") + fmt.Printf("==============\n\n") + fmt.Printf("Name: %s\n", selectedFleet.Label) + fmt.Printf("UID: %s\n", selectedFleet.UID) + if !selectedFleet.Created.IsZero() { + fmt.Printf("Created: %s\n", selectedFleet.Created.Format("2006-01-02 15:04:05 MST")) + } + if selectedFleet.SmartRule != "" { + fmt.Printf("Smart Rule: %s\n", selectedFleet.SmartRule) + } + if selectedFleet.ConnectivityAssurance != nil { + status := "disabled" + if selectedFleet.ConnectivityAssurance.Enabled { + status = "enabled" + } + fmt.Printf("Connectivity Assurance: %s\n", status) + } + if selectedFleet.WatchdogMins > 0 { + fmt.Printf("Watchdog: %d minutes\n", selectedFleet.WatchdogMins) + } + + // Display environment variables if any + if len(selectedFleet.EnvironmentVariables) > 0 { + fmt.Printf("\nEnvironment Variables:\n") + for key, value := range selectedFleet.EnvironmentVariables { + fmt.Printf(" %s: %s\n", key, value) + } + } + + fmt.Println() + + return nil + }, +} + +// fleetCreateCmd represents the fleet create command +var fleetCreateCmd = &cobra.Command{ + Use: "create [name]", + Short: "Create a new fleet", + Long: `Create a new fleet in the current project.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + fleetName := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Get optional flags + smartRule, _ := cmd.Flags().GetString("smart-rule") + connectivityAssurance, _ := cmd.Flags().GetBool("connectivity-assurance") + + // Define fleet types + type ConnectivityAssurance struct { + Enabled bool `json:"enabled"` + } + + type CreateFleetRequest struct { + Label string `json:"label"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + } + + type Fleet struct { + UID string `json:"uid"` + Label string `json:"label"` + Created time.Time `json:"created,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins int `json:"watchdog_mins,omitempty"` + } + + // Build create request + createReq := CreateFleetRequest{ + Label: fleetName, + } + + if smartRule != "" { + createReq.SmartRule = smartRule + } + + if cmd.Flags().Changed("connectivity-assurance") { + createReq.ConnectivityAssurance = &ConnectivityAssurance{ + Enabled: connectivityAssurance, + } + } + + // Marshal request to JSON + reqBody, err := note.JSONMarshal(createReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Create fleet using V1 API: POST /v1/projects/{projectUID}/fleets + createdFleet := Fleet{} + url := fmt.Sprintf("/v1/projects/%s/fleets", projectUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "POST", url, reqBody, &createdFleet) + if err != nil { + return fmt.Errorf("failed to create fleet: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(createdFleet, "", " ") + } else { + output, err = note.JSONMarshal(createdFleet) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display success message + fmt.Printf("\nFleet created successfully!\n\n") + fmt.Printf("Name: %s\n", createdFleet.Label) + fmt.Printf("UID: %s\n", createdFleet.UID) + if !createdFleet.Created.IsZero() { + fmt.Printf("Created: %s\n", createdFleet.Created.Format("2006-01-02 15:04:05 MST")) + } + if createdFleet.SmartRule != "" { + fmt.Printf("Smart Rule: %s\n", createdFleet.SmartRule) + } + if createdFleet.ConnectivityAssurance != nil { + status := "disabled" + if createdFleet.ConnectivityAssurance.Enabled { + status = "enabled" + } + fmt.Printf("Connectivity Assurance: %s\n", status) + } + fmt.Println() + + return nil + }, +} + +// fleetDeleteCmd represents the fleet delete command +var fleetDeleteCmd = &cobra.Command{ + Use: "delete [fleet-uid-or-name]", + Short: "Delete a fleet", + Long: `Delete a fleet from the current project.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + fleetIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define fleet types + type ConnectivityAssurance struct { + Enabled bool `json:"enabled"` + } + + type Fleet struct { + UID string `json:"uid"` + Label string `json:"label"` + Created time.Time `json:"created,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins int `json:"watchdog_mins,omitempty"` + } + + type FleetsResponse struct { + Fleets []Fleet `json:"fleets"` + } + + // Determine the fleet UID + var fleetUID string + var fleetName string + + // First, try to use it directly as a UID + url := fmt.Sprintf("/v1/projects/%s/fleets/%s", projectUID, fleetIdentifier) + var testFleet Fleet + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &testFleet) + + if err == nil && testFleet.UID != "" { + // It's a valid UID + fleetUID = testFleet.UID + fleetName = testFleet.Label + } else { + // Try to find by name + fleetsRsp := FleetsResponse{} + listURL := fmt.Sprintf("/v1/projects/%s/fleets", projectUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "GET", listURL, nil, &fleetsRsp) + if err != nil { + return fmt.Errorf("failed to list fleets: %w", err) + } + + // Search for fleet by name (exact match) + found := false + for _, fleet := range fleetsRsp.Fleets { + if fleet.Label == fleetIdentifier { + fleetUID = fleet.UID + fleetName = fleet.Label + found = true + break + } + } + + if !found { + return fmt.Errorf("fleet '%s' not found in project", fleetIdentifier) + } + } + + // Delete fleet using V1 API: DELETE /v1/projects/{projectUID}/fleets/{fleetUID} + deleteURL := fmt.Sprintf("/v1/projects/%s/fleets/%s", projectUID, fleetUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "DELETE", deleteURL, nil, nil) + if err != nil { + return fmt.Errorf("failed to delete fleet: %w", err) + } + + fmt.Printf("\nFleet '%s' (UID: %s) deleted successfully.\n\n", fleetName, fleetUID) + + return nil + }, +} + +// fleetUpdateCmd represents the fleet update command +var fleetUpdateCmd = &cobra.Command{ + Use: "update [fleet-uid-or-name]", + Short: "Update a fleet", + Long: `Update a fleet's properties such as name, smart rule, connectivity assurance, or watchdog timer.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + fleetIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Get optional flags + newName, _ := cmd.Flags().GetString("name") + smartRule, _ := cmd.Flags().GetString("smart-rule") + connectivityAssurance, _ := cmd.Flags().GetBool("connectivity-assurance") + watchdogMins, _ := cmd.Flags().GetInt("watchdog-mins") + + // Check if any update flags were provided + if !cmd.Flags().Changed("name") && + !cmd.Flags().Changed("smart-rule") && + !cmd.Flags().Changed("connectivity-assurance") && + !cmd.Flags().Changed("watchdog-mins") { + return fmt.Errorf("no update flags provided. Use --name, --smart-rule, --connectivity-assurance, or --watchdog-mins") + } + + // Define fleet types + type ConnectivityAssurance struct { + Enabled bool `json:"enabled"` + } + + type Fleet struct { + UID string `json:"uid"` + Label string `json:"label"` + Created time.Time `json:"created,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins int `json:"watchdog_mins,omitempty"` + } + + type FleetsResponse struct { + Fleets []Fleet `json:"fleets"` + } + + type UpdateFleetRequest struct { + Label string `json:"label,omitempty"` + SmartRule string `json:"smart_rule,omitempty"` + ConnectivityAssurance *ConnectivityAssurance `json:"connectivity_assurance,omitempty"` + WatchdogMins *int `json:"watchdog_mins,omitempty"` + } + + // Determine the fleet UID + var fleetUID string + + // First, try to use it directly as a UID + url := fmt.Sprintf("/v1/projects/%s/fleets/%s", projectUID, fleetIdentifier) + var testFleet Fleet + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &testFleet) + + if err == nil && testFleet.UID != "" { + // It's a valid UID + fleetUID = testFleet.UID + } else { + // Try to find by name + fleetsRsp := FleetsResponse{} + listURL := fmt.Sprintf("/v1/projects/%s/fleets", projectUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "GET", listURL, nil, &fleetsRsp) + if err != nil { + return fmt.Errorf("failed to list fleets: %w", err) + } + + // Search for fleet by name (exact match) + found := false + for _, fleet := range fleetsRsp.Fleets { + if fleet.Label == fleetIdentifier { + fleetUID = fleet.UID + found = true + break + } + } + + if !found { + return fmt.Errorf("fleet '%s' not found in project", fleetIdentifier) + } + } + + // Build update request + updateReq := UpdateFleetRequest{} + + if cmd.Flags().Changed("name") { + updateReq.Label = newName + } + + if cmd.Flags().Changed("smart-rule") { + updateReq.SmartRule = smartRule + } + + if cmd.Flags().Changed("connectivity-assurance") { + updateReq.ConnectivityAssurance = &ConnectivityAssurance{ + Enabled: connectivityAssurance, + } + } + + if cmd.Flags().Changed("watchdog-mins") { + updateReq.WatchdogMins = &watchdogMins + } + + // Marshal request to JSON + reqBody, err := note.JSONMarshal(updateReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Update fleet using V1 API: PUT /v1/projects/{projectUID}/fleets/{fleetUID} + updatedFleet := Fleet{} + updateURL := fmt.Sprintf("/v1/projects/%s/fleets/%s", projectUID, fleetUID) + err = reqHubV1(GetVerbose(), GetAPIHub(), "PUT", updateURL, reqBody, &updatedFleet) + if err != nil { + return fmt.Errorf("failed to update fleet: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(updatedFleet, "", " ") + } else { + output, err = note.JSONMarshal(updatedFleet) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display success message + fmt.Printf("\nFleet updated successfully!\n\n") + fmt.Printf("Name: %s\n", updatedFleet.Label) + fmt.Printf("UID: %s\n", updatedFleet.UID) + if !updatedFleet.Created.IsZero() { + fmt.Printf("Created: %s\n", updatedFleet.Created.Format("2006-01-02 15:04:05 MST")) + } + if updatedFleet.SmartRule != "" { + fmt.Printf("Smart Rule: %s\n", updatedFleet.SmartRule) + } + if updatedFleet.ConnectivityAssurance != nil { + status := "disabled" + if updatedFleet.ConnectivityAssurance.Enabled { + status = "enabled" + } + fmt.Printf("Connectivity Assurance: %s\n", status) + } + if updatedFleet.WatchdogMins > 0 { + fmt.Printf("Watchdog: %d minutes\n", updatedFleet.WatchdogMins) + } + fmt.Println() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(fleetCmd) + fleetCmd.AddCommand(fleetListCmd) + fleetCmd.AddCommand(fleetGetCmd) + fleetCmd.AddCommand(fleetCreateCmd) + fleetCmd.AddCommand(fleetDeleteCmd) + fleetCmd.AddCommand(fleetUpdateCmd) + + // Add flags for fleet create + fleetCreateCmd.Flags().String("smart-rule", "", "JSONata expression for dynamic fleet membership") + fleetCreateCmd.Flags().Bool("connectivity-assurance", false, "Enable connectivity assurance for this fleet") + + // Add flags for fleet update + fleetUpdateCmd.Flags().String("name", "", "New name for the fleet") + fleetUpdateCmd.Flags().String("smart-rule", "", "JSONata expression for dynamic fleet membership") + fleetUpdateCmd.Flags().Bool("connectivity-assurance", false, "Enable or disable connectivity assurance") + fleetUpdateCmd.Flags().Int("watchdog-mins", 0, "Watchdog timer in minutes (0 to disable)") +} diff --git a/notehub/app.go b/notehub/cmd/helpers.go similarity index 50% rename from notehub/app.go rename to notehub/cmd/helpers.go index 8bdd76d..e17fdf6 100644 --- a/notehub/app.go +++ b/notehub/cmd/helpers.go @@ -2,7 +2,7 @@ // Use of this source code is governed by licenses granted by the // copyright holder including that found in the LICENSE file. -package main +package cmd import ( "bufio" @@ -12,10 +12,13 @@ import ( "sort" "strings" - "github.com/blues/note-cli/lib" + "github.com/blues/note-go/note" notegoapi "github.com/blues/note-go/notehub/api" ) +// Type definitions +type Vars map[string]string + type Metadata struct { Name string `json:"name,omitempty"` UID string `json:"uid,omitempty"` @@ -32,37 +35,52 @@ type AppMetadata struct { // Load metadata for the app func appGetMetadata(flagVerbose bool, flagVars bool) (appMetadata AppMetadata, err error) { + // Get project info using V1 API + // First we need to determine the project UID from global flags + projectUID := GetProject() + if projectUID == "" { + product := GetProduct() + if product != "" { + projectUID = product + } + } - rsp := map[string]interface{}{} - err = reqHubV0(flagVerbose, lib.ConfigAPIHub(), []byte("{\"req\":\"hub.app.get\"}"), "", "", "", "", false, false, nil, &rsp) - if err != nil { - return + if projectUID == "" { + return appMetadata, fmt.Errorf("project or product UID required") } - rsperr, _ := rsp["err"].(string) - if rsperr != "" { - err = fmt.Errorf("%s", rsperr) + + // Get project information using V1 API: GET /v1/projects/{projectOrProductUID} + projectRsp := map[string]interface{}{} + projectURL := fmt.Sprintf("/v1/projects/%s", projectUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", projectURL, nil, &projectRsp) + if err != nil { return } // App info - appMetadata.App.UID, _ = rsp["uid"].(string) - appMetadata.App.Name, _ = rsp["label"].(string) - appMetadata.App.BA, _ = rsp["billing_account_uid"].(string) - - // Fleet info - settings, exists := rsp["info"].(map[string]interface{}) - if exists { - fleets, exists := settings["fleet"].(map[string]interface{}) + appMetadata.App.UID, _ = projectRsp["uid"].(string) + appMetadata.App.Name, _ = projectRsp["label"].(string) + appMetadata.App.BA, _ = projectRsp["billing_account_uid"].(string) + + // Get fleets using V1 API: GET /v1/projects/{projectOrProductUID}/fleets + fleetsRsp := map[string]interface{}{} + fleetsURL := fmt.Sprintf("/v1/projects/%s/fleets", appMetadata.App.UID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", fleetsURL, nil, &fleetsRsp) + if err == nil { + fleetsList, exists := fleetsRsp["fleets"].([]interface{}) if exists { items := []Metadata{} - for k, v := range fleets { - vj, ok := v.(map[string]interface{}) + for _, v := range fleetsList { + fleet, ok := v.(map[string]interface{}) if ok { - i := Metadata{Name: vj["label"].(string), UID: k} + fleetUID, _ := fleet["uid"].(string) + fleetLabel, _ := fleet["label"].(string) + i := Metadata{Name: fleetLabel, UID: fleetUID} + if flagVars { varsRsp := notegoapi.GetFleetEnvironmentVariablesResponse{} - url := fmt.Sprintf("/v1/projects/%s/fleets/%s/environment_variables", appMetadata.App.UID, k) - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", url, nil, &varsRsp) + url := fmt.Sprintf("/v1/projects/%s/fleets/%s/environment_variables", appMetadata.App.UID, fleetUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", url, nil, &varsRsp) if err != nil { return } @@ -75,36 +93,27 @@ func appGetMetadata(flagVerbose bool, flagVars bool) (appMetadata AppMetadata, e } } - // Enum routes - rsp = map[string]interface{}{} - err = reqHubV0(flagVerbose, lib.ConfigAPIHub(), []byte("{\"req\":\"hub.app.test.route\"}"), "", "", "", "", false, false, nil, &rsp) - rsperr, _ = rsp["err"].(string) - if rsperr != "" { - err = fmt.Errorf("%s", rsperr) - } + // Get routes using V1 API: GET /v1/projects/{projectOrProductUID}/routes + routesRsp := []map[string]interface{}{} + routesURL := fmt.Sprintf("/v1/projects/%s/routes", appMetadata.App.UID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", routesURL, nil, &routesRsp) if err == nil { - body, exists := rsp["body"].(map[string]interface{}) - if exists { - items := []Metadata{} - for k, v := range body { - vs, ok := v.(string) - if ok { - components := strings.Split(k, "/") - if len(components) > 1 { - i := Metadata{Name: vs, UID: components[1]} - items = append(items, i) - } - } - } - appMetadata.Routes = items + items := []Metadata{} + for _, route := range routesRsp { + routeUID, _ := route["uid"].(string) + routeLabel, _ := route["label"].(string) + i := Metadata{Name: routeLabel, UID: routeUID} + items = append(items, i) } + appMetadata.Routes = items } - // Products - rsp = map[string]interface{}{} - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", "/v1/projects/"+appMetadata.App.UID+"/products", nil, &rsp) + // Get products using V1 API: GET /v1/projects/{projectOrProductUID}/products + productsRsp := map[string]interface{}{} + productsURL := fmt.Sprintf("/v1/projects/%s/products", appMetadata.App.UID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", productsURL, nil, &productsRsp) if err == nil { - pi, exists := rsp["products"].([]interface{}) + pi, exists := productsRsp["products"].([]interface{}) if exists { items := []Metadata{} for _, v := range pi { @@ -118,15 +127,12 @@ func appGetMetadata(flagVerbose bool, flagVars bool) (appMetadata AppMetadata, e } } - // Done return - } -// Get a device list given +// Get a device list given a scope func appGetScope(scope string, flagVerbose bool) (appMetadata AppMetadata, scopeDevices []string, scopeFleets []string, err error) { - - // Process special scopes, which are handled inside addScope + // Process special scopes switch scope { case "devices": scope = "@" @@ -134,13 +140,13 @@ func appGetScope(scope string, flagVerbose bool) (appMetadata AppMetadata, scope scope = "-" } - // Get the metadata before we begin, because at a minimum we need appUID + // Get the metadata appMetadata, err = appGetMetadata(flagVerbose, false) if err != nil { return } - // On the command line (but not inside files) we allow comma-separated lists + // On the command line we allow comma-separated lists if strings.Contains(scope, ",") { scopeList := strings.Split(scope, ",") for _, scope := range scopeList { @@ -160,21 +166,17 @@ func appGetScope(scope string, flagVerbose bool) (appMetadata AppMetadata, scope scopeDevices = sortAndRemoveDuplicates(scopeDevices) scopeFleets = sortAndRemoveDuplicates(scopeFleets) - // Done return - } // Recursively add scope func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, scopeFleets *[]string, flagVerbose bool) (err error) { - if strings.HasPrefix(scope, "dev:") { *scopeDevices = append(*scopeDevices, scope) return } if strings.HasPrefix(scope, "imei:") || strings.HasPrefix(scope, "burn:") { - // This is a pre-V1 legacy that still exists in some ancient fleets *scopeDevices = append(*scopeDevices, scope) return } @@ -184,7 +186,7 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc return } - // See if this is a fleet name, and translate it to an ID + // See if this is a fleet name if !strings.HasPrefix(scope, "@") { found := false for _, fleet := range (*appMetadata).Fleets { @@ -199,7 +201,7 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc return } - // Process a fleet indirection. First, find the fleet. + // Process indirection indirectScope := strings.TrimPrefix(scope, "@") foundFleet := false lookingFor := strings.TrimSpace(indirectScope) @@ -207,7 +209,6 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc // Looking for "all devices" or a named fleet if indirectScope == "" { // All devices - pageSize := 500 pageNum := 0 for { @@ -215,7 +216,7 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc devices := notegoapi.GetDevicesResponse{} url := fmt.Sprintf("/v1/projects/%s/devices?pageSize=%d&pageNum=%d", appMetadata.App.UID, pageSize, pageNum) - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", url, nil, &devices) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", url, nil, &devices) if err != nil { return } @@ -230,13 +231,9 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc if !devices.HasMore { break } - } - return - } else { - // Fleet for _, fleet := range (*appMetadata).Fleets { if lookingFor == fleet.UID || fleetMatchesScope(fleet.Name, lookingFor) { @@ -249,7 +246,7 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc devices := notegoapi.GetDevicesResponse{} url := fmt.Sprintf("/v1/projects/%s/fleets/%s/devices?pageSize=%d&pageNum=%d", appMetadata.App.UID, fleet.UID, pageSize, pageNum) - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", url, nil, &devices) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", url, nil, &devices) if err != nil { return } @@ -264,15 +261,12 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc if !devices.HasMore { break } - } - } } if foundFleet { return } - } // Process a file indirection @@ -297,12 +291,10 @@ func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, sc err = scanner.Err() return - } -// Sort and remove duplicates in a string slice +// Sort and remove duplicates func sortAndRemoveDuplicates(strings []string) []string { - sort.Strings(strings) unique := make(map[string]struct{}) @@ -335,3 +327,116 @@ func fleetMatchesScope(fleetName string, scope string) bool { } return match } + +// Load env vars from devices +func varsGetFromDevices(appMetadata AppMetadata, uids []string, flagVerbose bool) (vars map[string]Vars, err error) { + vars = map[string]Vars{} + + for _, deviceUID := range uids { + varsRsp := notegoapi.GetDeviceEnvironmentVariablesResponse{} + url := fmt.Sprintf("/v1/projects/%s/devices/%s/environment_variables", appMetadata.App.UID, deviceUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", url, nil, &varsRsp) + if err != nil { + return + } + vars[deviceUID] = varsRsp.EnvironmentVariables + } + + return +} + +// Load env vars from fleets +func varsGetFromFleets(appMetadata AppMetadata, uids []string, flagVerbose bool) (vars map[string]Vars, err error) { + vars = map[string]Vars{} + + for _, fleetUID := range uids { + varsRsp := notegoapi.GetFleetEnvironmentVariablesResponse{} + url := fmt.Sprintf("/v1/projects/%s/fleets/%s/environment_variables", appMetadata.App.UID, fleetUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "GET", url, nil, &varsRsp) + if err != nil { + return + } + vars[fleetUID] = varsRsp.EnvironmentVariables + } + + return +} + +// Set env vars for devices +func varsSetFromDevices(appMetadata AppMetadata, uids []string, template Vars, flagVerbose bool) (vars map[string]Vars, err error) { + vars = map[string]Vars{} + + for _, deviceUID := range uids { + req := notegoapi.PutDeviceEnvironmentVariablesRequest{EnvironmentVariables: Vars{}} + for k, v := range template { + req.EnvironmentVariables[k] = v + } + + var reqJSON []byte + reqJSON, err = note.JSONMarshal(req) + if err != nil { + return + } + + rspPut := notegoapi.PutDeviceEnvironmentVariablesResponse{} + url := fmt.Sprintf("/v1/projects/%s/devices/%s/environment_variables", appMetadata.App.UID, deviceUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "PUT", url, reqJSON, &rspPut) + if err != nil { + return + } + + vars[deviceUID] = rspPut.EnvironmentVariables + } + + return +} + +// Set env vars for fleets +func varsSetFromFleets(appMetadata AppMetadata, uids []string, template Vars, flagVerbose bool) (vars map[string]Vars, err error) { + vars = map[string]Vars{} + + for _, fleetUID := range uids { + req := notegoapi.PutFleetEnvironmentVariablesRequest{EnvironmentVariables: Vars{}} + for k, v := range template { + req.EnvironmentVariables[k] = v + } + + var reqJSON []byte + reqJSON, err = note.JSONMarshal(req) + if err != nil { + return + } + + rspPut := notegoapi.PutFleetEnvironmentVariablesResponse{} + url := fmt.Sprintf("/v1/projects/%s/fleets/%s/environment_variables", appMetadata.App.UID, fleetUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "PUT", url, reqJSON, &rspPut) + if err != nil { + return + } + + vars[fleetUID] = rspPut.EnvironmentVariables + } + + return +} + +// Provision devices +func varsProvisionDevices(appMetadata AppMetadata, uids []string, productUID string, deviceSN string, flagVerbose bool) (err error) { + for _, deviceUID := range uids { + req := notegoapi.ProvisionDeviceRequest{ProductUID: productUID, DeviceSN: deviceSN} + + var reqJSON []byte + reqJSON, err = note.JSONMarshal(req) + if err != nil { + return + } + + url := fmt.Sprintf("/v1/projects/%s/devices/%s/provision", appMetadata.App.UID, deviceUID) + err = reqHubV1(flagVerbose, GetAPIHub(), "POST", url, reqJSON, nil) + if err != nil { + return + } + } + + return +} diff --git a/notehub/cmd/product.go b/notehub/cmd/product.go new file mode 100644 index 0000000..816234f --- /dev/null +++ b/notehub/cmd/product.go @@ -0,0 +1,168 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +// productCmd represents the product command +var productCmd = &cobra.Command{ + Use: "product", + Short: "Manage Notehub products", + Long: `Commands for listing and managing products in Notehub projects.`, +} + +// productListCmd represents the product list command +var productListCmd = &cobra.Command{ + Use: "list", + Short: "List all products in a project", + Long: `List all products in the current project or a specified project.`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define product types + type Product struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProductsResponse struct { + Products []Product `json:"products"` + } + + // Get products using V1 API: GET /v1/projects/{projectUID}/products + productsRsp := ProductsResponse{} + url := fmt.Sprintf("/v1/projects/%s/products", projectUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &productsRsp) + if err != nil { + return fmt.Errorf("failed to list products: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(productsRsp, "", " ") + } else { + output, err = note.JSONMarshal(productsRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(productsRsp.Products) == 0 { + fmt.Println("No products found in this project.") + return nil + } + + // Display products in human-readable format + fmt.Printf("\nProducts in Project:\n") + fmt.Printf("====================\n\n") + + for _, product := range productsRsp.Products { + fmt.Printf(" %s\n", product.Label) + fmt.Printf(" %s\n\n", product.UID) + } + + fmt.Printf("Total products: %d\n\n", len(productsRsp.Products)) + + return nil + }, +} + +// productGetCmd represents the product get command +var productGetCmd = &cobra.Command{ + Use: "get [product-uid-or-name]", + Short: "Get details about a specific product", + Long: `Get detailed information about a specific product by UID or name.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + productIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + // Define product type + type Product struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProductsResponse struct { + Products []Product `json:"products"` + } + + // Get all products and find the matching one + productsRsp := ProductsResponse{} + url := fmt.Sprintf("/v1/projects/%s/products", projectUID) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &productsRsp) + if err != nil { + return fmt.Errorf("failed to list products: %w", err) + } + + // Find the product by UID or name + var foundProduct *Product + for _, product := range productsRsp.Products { + if product.UID == productIdentifier || product.Label == productIdentifier { + foundProduct = &product + break + } + } + + if foundProduct == nil { + return fmt.Errorf("product '%s' not found in project", productIdentifier) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(foundProduct, "", " ") + } else { + output, err = note.JSONMarshal(foundProduct) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display product in human-readable format + fmt.Printf("\nProduct Details:\n") + fmt.Printf("================\n\n") + fmt.Printf("Name: %s\n", foundProduct.Label) + fmt.Printf("UID: %s\n", foundProduct.UID) + fmt.Println() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(productCmd) + productCmd.AddCommand(productListCmd) + productCmd.AddCommand(productGetCmd) +} diff --git a/notehub/cmd/project.go b/notehub/cmd/project.go new file mode 100644 index 0000000..1fc01e4 --- /dev/null +++ b/notehub/cmd/project.go @@ -0,0 +1,322 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// projectCmd represents the project command +var projectCmd = &cobra.Command{ + Use: "project", + Short: "Manage Notehub projects", + Long: `Commands for listing and selecting Notehub projects to work with.`, +} + +// projectListCmd represents the project list command +var projectListCmd = &cobra.Command{ + Use: "list", + Short: "List all projects", + Long: `List all Notehub projects for the authenticated user.`, + RunE: func(cmd *cobra.Command, args []string) error { + credentials := GetCredentials() // Validates and exits if not authenticated + + // Get all projects using V1 API: GET /v1/projects + type Project struct { + UID string `json:"uid"` + Label string `json:"label"` + BillingAccountUID string `json:"billing_account_uid"` + DisableDevicesByDefault bool `json:"disable_devices_by_default"` + } + + type ProjectsResponse struct { + Projects []Project `json:"projects"` + } + + projectsRsp := ProjectsResponse{} + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", "/v1/projects", nil, &projectsRsp) + if err != nil { + return fmt.Errorf("failed to list projects: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(projectsRsp, "", " ") + } else { + output, err = note.JSONMarshal(projectsRsp) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(projectsRsp.Projects) == 0 { + fmt.Println("No projects found.") + fmt.Println("\nYou can create a new project at https://notehub.io") + return nil + } + + // Check current project + currentProject := GetProject() + + // Display projects in human-readable format + fmt.Println("\nAvailable Projects:") + fmt.Println("===================") + for _, project := range projectsRsp.Projects { + if project.UID == currentProject { + fmt.Printf("* %s (current)\n", project.Label) + fmt.Printf(" %s\n\n", project.UID) + } else { + fmt.Printf(" %s\n", project.Label) + fmt.Printf(" %s\n\n", project.UID) + } + } + + if currentProject == "" { + fmt.Println("No project selected. Use 'notehub project set ' to select one.\n") + } + + // Show credentials user + fmt.Printf("Signed in as: %s\n\n", credentials.User) + + return nil + }, +} + +// projectSetCmd represents the project set command +var projectSetCmd = &cobra.Command{ + Use: "set [project-name-or-uid]", + Short: "Set the active project", + Long: `Set the active project in the configuration. You can specify either the project name or UID.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + projectIdentifier := args[0] + + // Define project types + type Project struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProjectsResponse struct { + Projects []Project `json:"projects"` + } + + // First, try to use it directly as a UID + var selectedProject Project + url := fmt.Sprintf("/v1/projects/%s", projectIdentifier) + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", url, nil, &selectedProject) + + // If that failed or returned empty UID, it might be a project name - fetch all projects and search + if err != nil || selectedProject.UID == "" { + projectsRsp := ProjectsResponse{} + err = reqHubV1(GetVerbose(), GetAPIHub(), "GET", "/v1/projects", nil, &projectsRsp) + if err != nil { + return fmt.Errorf("failed to list projects: %w", err) + } + + // Search for project by name (exact match) + found := false + for _, project := range projectsRsp.Projects { + if project.Label == projectIdentifier { + selectedProject = project + found = true + break + } + } + + if !found { + return fmt.Errorf("project '%s' not found. Use 'notehub project list' to see available projects", projectIdentifier) + } + } + + // Save to config + viper.Set("project", selectedProject.UID) + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Active project set to: %s\n", selectedProject.Label) + fmt.Printf("Project UID: %s\n", selectedProject.UID) + fmt.Println("\nThis project will now be used as the default for all commands.") + + return nil + }, +} + +// projectGetCmd represents the project get command +var projectGetCmd = &cobra.Command{ + Use: "get [project-name-or-uid]", + Short: "Get detailed information about a project", + Long: `Get detailed information about a specific project. If no project is specified, uses the active project. + +Examples: + # Get information about active project + notehub project get + + # Get information about specific project by UID + notehub project get app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + # Get information about specific project by name + notehub project get "My Project" + + # Get with JSON output + notehub project get --json + + # Get with pretty JSON + notehub project get --pretty`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + var projectUID string + + // If project specified, use that; otherwise use active project + if len(args) > 0 { + projectIdentifier := args[0] + + // First try to use it directly as a UID + if len(projectIdentifier) > 4 && projectIdentifier[:4] == "app:" { + projectUID = projectIdentifier + } else { + // It might be a project name - fetch all projects and search + type Project struct { + UID string `json:"uid"` + Label string `json:"label"` + } + + type ProjectsResponse struct { + Projects []Project `json:"projects"` + } + + projectsRsp := ProjectsResponse{} + err := reqHubV1(GetVerbose(), GetAPIHub(), "GET", "/v1/projects", nil, &projectsRsp) + if err != nil { + return fmt.Errorf("failed to list projects: %w", err) + } + + // Search for project by name (exact match) + found := false + for _, project := range projectsRsp.Projects { + if project.Label == projectIdentifier { + projectUID = project.UID + found = true + break + } + } + + if !found { + return fmt.Errorf("project '%s' not found. Use 'notehub project list' to see available projects", projectIdentifier) + } + } + } else { + // Use active project + projectUID = GetProject() + if projectUID == "" { + return fmt.Errorf("no project specified and no active project set. Use 'notehub project set ' to set an active project") + } + } + + verbose := GetVerbose() + + // Get project details using V1 API: GET /v1/projects/{projectUID} + var project map[string]interface{} + url := fmt.Sprintf("/v1/projects/%s", projectUID) + err := reqHubV1(verbose, GetAPIHub(), "GET", url, nil, &project) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(project, "", " ") + } else { + output, err = note.JSONMarshal(project) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display project in human-readable format + uid, _ := project["uid"].(string) + label, _ := project["label"].(string) + billingAccountUID, _ := project["billing_account_uid"].(string) + disableDevicesByDefault, _ := project["disable_devices_by_default"].(bool) + + // Check if this is the active project + currentProject := GetProject() + isActive := (uid == currentProject) + + fmt.Printf("\nProject Details:\n") + fmt.Printf("================\n\n") + fmt.Printf("Name: %s", label) + if isActive { + fmt.Printf(" (active)") + } + fmt.Println() + fmt.Printf("UID: %s\n", uid) + if billingAccountUID != "" { + fmt.Printf("Billing Account: %s\n", billingAccountUID) + } + if disableDevicesByDefault { + fmt.Printf("Disable Devices by Default: Yes\n") + } else { + fmt.Printf("Disable Devices by Default: No\n") + } + fmt.Println() + + return nil + }, +} + +// projectClearCmd represents the project clear command +var projectClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear the active project", + Long: `Clear the active project from the configuration.`, + RunE: func(cmd *cobra.Command, args []string) error { + currentProject := GetProject() + if currentProject == "" { + fmt.Println("No project is currently set.") + return nil + } + + // Clear from config + viper.Set("project", "") + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Active project cleared.") + fmt.Println("You can set a new project with 'notehub project set '") + + return nil + }, +} + +func init() { + rootCmd.AddCommand(projectCmd) + projectCmd.AddCommand(projectListCmd) + projectCmd.AddCommand(projectGetCmd) + projectCmd.AddCommand(projectSetCmd) + projectCmd.AddCommand(projectClearCmd) +} diff --git a/notehub/cmd/provision.go b/notehub/cmd/provision.go new file mode 100644 index 0000000..bc13feb --- /dev/null +++ b/notehub/cmd/provision.go @@ -0,0 +1,89 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// provisionCmd represents the provision command +var provisionCmd = &cobra.Command{ + Use: "provision", + Short: "Provision devices to a product", + Long: `Provision devices to a product. + +The --product flag specifies the target product UID. The --project flag (or +config file) specifies which project contains the devices to provision. +Command-line flags override config file values. + +Scope Formats: + dev:xxxx Single device UID + imei:xxxx Device by IMEI + fleet:xxxx All devices in fleet (by UID) + production All devices in named fleet + @fleet-name All devices in fleet (indirection) + @ All devices in project + @devices.txt Device UIDs from file (one per line) + dev:aaa,dev:bbb Multiple scopes (comma-separated) + +Examples: + # Provision a single device (project from config) + notehub provision --scope dev:864475046552567 --product com.company:product + + # Provision all devices in a fleet + notehub provision --scope @production --product com.company:product + + # Provision all devices in project + notehub provision --scope @ --product com.company:product + + # Provision devices from a file + notehub provision --scope @devices.txt --product com.company:product + + # Override project from command line + notehub provision --scope dev:xxxx --product com.company:product --project app:xxxx + + # Provision with serial number + notehub provision --scope dev:xxxx --product com.company:product --sn SENSOR-001`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + if flagScope == "" { + return fmt.Errorf("use --scope to specify device(s) to be provisioned") + } + + product := GetProduct() + if product == "" { + return fmt.Errorf("--product must be specified (the product UID to provision devices to)") + } + + verbose := GetVerbose() + appMetadata, scopeDevices, _, err := appGetScope(flagScope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 { + return fmt.Errorf("no devices to provision") + } + + err = varsProvisionDevices(appMetadata, scopeDevices, product, flagSn, verbose) + if err != nil { + return err + } + + fmt.Printf("Successfully provisioned %d device(s) to product %s\n", len(scopeDevices), product) + return nil + }, +} + +func init() { + rootCmd.AddCommand(provisionCmd) + + provisionCmd.Flags().StringVarP(&flagScope, "scope", "s", "", "Device scope (required)") + provisionCmd.Flags().StringVar(&flagSn, "sn", "", "Serial number for provisioning") + provisionCmd.MarkFlagRequired("scope") +} diff --git a/notehub/req.go b/notehub/cmd/req.go similarity index 75% rename from notehub/req.go rename to notehub/cmd/req.go index d60b5b7..a8fb26e 100644 --- a/notehub/req.go +++ b/notehub/cmd/req.go @@ -2,7 +2,7 @@ // Use of this source code is governed by licenses granted by the // copyright holder including that found in the LICENSE file. -package main +package cmd import ( "bytes" @@ -13,11 +13,15 @@ import ( "os" "strings" - "github.com/blues/note-cli/lib" "github.com/blues/note-go/note" "github.com/blues/note-go/notehub" ) +// Used by req functions +var reqFlagApp string +var reqFlagProduct string +var reqFlagDevice string + // Add an arg to an URL query string func addQuery(in string, key string, value string) (out string) { out = in @@ -35,14 +39,16 @@ func addQuery(in string, key string, value string) (out string) { return } -// Perform a hub transaction, and promote the returned err response to an error to this method +// Perform a hub transaction using V0 Notecard API format, and promote the returned err response to an error to this method +// Note: This is used for device-specific Notecard communication APIs (file.changes, note.changes, etc.) +// which are distinct from Notehub project management APIs that use V1 REST endpoints. func hubTransactionRequest(request notehub.HubRequest, verbose bool) (rsp notehub.HubRequest, err error) { var reqJSON []byte reqJSON, err = note.JSONMarshal(request) if err != nil { return } - err = reqHubV0(verbose, lib.ConfigAPIHub(), reqJSON, "", "", "", "", false, false, nil, &rsp) + err = reqHubV0(verbose, GetAPIHub(), reqJSON, "", "", "", "", false, false, nil, &rsp) if err != nil { return } @@ -53,6 +59,8 @@ func hubTransactionRequest(request notehub.HubRequest, verbose bool) (rsp notehu } // Process a V0 HTTPS request and unmarshal into an object +// Note: V0 API is used for device-specific Notecard communication (file.changes, note.changes, etc.) +// For Notehub project management operations, use reqHubV1 instead. func reqHubV0(verbose bool, hub string, request []byte, requestFile string, filetype string, filetags string, filenotes string, overwrite bool, dropNonJSON bool, outq chan string, object interface{}) (err error) { var response []byte response, err = reqHubV0JSON(verbose, hub, request, requestFile, filetype, filetags, filenotes, overwrite, dropNonJSON, outq) @@ -67,7 +75,6 @@ func reqHubV0(verbose bool, hub string, request []byte, requestFile string, file // Perform a V0 HTTP request func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, filetype string, filetags string, filenotes string, overwrite bool, dropNonJSON bool, outq chan string) (response []byte, err error) { - fn := "" path := strings.Split(requestFile, "/") if len(path) > 0 { @@ -75,15 +82,15 @@ func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, } if hub == "" { - hub = lib.ConfigAPIHub() + hub = GetAPIHub() } httpurl := fmt.Sprintf("https://%s/req", hub) - query := addQuery("", "app", flagApp) - if flagApp == "" { - query = addQuery("", "product", flagProduct) + query := addQuery("", "app", reqFlagApp) + if reqFlagApp == "" { + query = addQuery("", "product", reqFlagProduct) } - query = addQuery(query, "device", flagDevice) + query = addQuery(query, "device", reqFlagDevice) query = addQuery(query, "upload", fn) if overwrite { query = addQuery(query, "overwrite", "true") @@ -123,7 +130,7 @@ func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, httpReq.Header.Set("Content-Type", "application/json") } - err = lib.ConfigAuthenticationHeader(httpReq) + err = AddAuthenticationHeader(httpReq) if err != nil { return } @@ -146,18 +153,13 @@ func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, for { n, err2 := httpRsp.Body.Read(b) if n > 0 { - // Append to result buffer if no outq is specified if outq == nil { - response = append(response, b[:n]...) - } else { - // Enqueue lines for monitoring linebuf = append(linebuf, b[:n]...) for { - // Parse out a full line and queue it, saving the leftover i := bytes.IndexRune(linebuf, '\n') if i == -1 { @@ -177,9 +179,7 @@ func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, // was an error and we're about to get an io.EOF response = line } - } - } if err2 != nil { if err2 != io.EOF { @@ -195,10 +195,11 @@ func reqHubV0JSON(verbose bool, hub string, request []byte, requestFile string, } return - } // Process a V1 HTTPS request and unmarshal into an object +// Note: V1 REST API is used for Notehub project management operations (projects, devices, fleets, routes, etc.) +// For device-specific Notecard communication, use reqHubV0 instead. func reqHubV1(verbose bool, hub string, verb string, url string, body []byte, object interface{}) (err error) { var response []byte response, err = reqHubV1JSON(verbose, hub, verb, url, body) @@ -213,7 +214,6 @@ func reqHubV1(verbose bool, hub string, verb string, url string, body []byte, ob // Process an HTTPS request func reqHubV1JSON(verbose bool, hub string, verb string, url string, body []byte) (response []byte, err error) { - verb = strings.ToUpper(verb) httpurl := fmt.Sprintf("https://%s%s", hub, url) @@ -227,7 +227,7 @@ func reqHubV1JSON(verbose bool, hub string, verb string, url string, body []byte } httpReq.Header.Set("User-Agent", "notehub-client") httpReq.Header.Set("Content-Type", "application/json") - err = lib.ConfigAuthenticationHeader(httpReq) + err = AddAuthenticationHeader(httpReq) if err != nil { return } @@ -245,11 +245,6 @@ func reqHubV1JSON(verbose bool, hub string, verb string, url string, body []byte err = err2 return } - if httpRsp.StatusCode == http.StatusUnauthorized { - err = fmt.Errorf("please use -signin to authenticate") - return - } - if verbose { fmt.Printf("STATUS %d\n", httpRsp.StatusCode) } @@ -263,6 +258,28 @@ func reqHubV1JSON(verbose bool, hub string, verb string, url string, body []byte fmt.Printf("%s\n", string(response)) } - return + // Check for HTTP error status codes + if httpRsp.StatusCode == http.StatusUnauthorized { + err = fmt.Errorf("please use -signin to authenticate") + return + } + // Check for other HTTP error status codes (4xx, 5xx) + if httpRsp.StatusCode >= 400 { + // Try to parse error message from response body + if len(response) > 0 { + var errResp map[string]interface{} + if unmarshalErr := note.JSONUnmarshal(response, &errResp); unmarshalErr == nil { + if errMsg, ok := errResp["err"].(string); ok { + err = fmt.Errorf("HTTP %d: %s", httpRsp.StatusCode, errMsg) + return + } + } + } + // Fallback to generic error if we couldn't parse the error message + err = fmt.Errorf("HTTP %d: %s", httpRsp.StatusCode, http.StatusText(httpRsp.StatusCode)) + return + } + + return } diff --git a/notehub/cmd/request.go b/notehub/cmd/request.go new file mode 100644 index 0000000..83d4d85 --- /dev/null +++ b/notehub/cmd/request.go @@ -0,0 +1,90 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +var ( + flagOut string +) + +// requestCmd represents the request command +var requestCmd = &cobra.Command{ + Use: "request [json]", + Aliases: []string{"req"}, + Short: "Send an API request to Notehub", + Long: `Send a raw API request to Notehub. + +The JSON argument can be a JSON object or @filename to read from a file. + +Example: + notehub request '{"req":"hub.app.get"}' --project app:xxxx + notehub req @request.json --device dev:xxxx --pretty`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + reqArg := args[0] + + // Process request starting with @ as a filename + if strings.HasPrefix(reqArg, "@") { + fn := strings.TrimPrefix(reqArg, "@") + contents, err := os.ReadFile(fn) + if err != nil { + return fmt.Errorf("can't read request file '%s': %w", fn, err) + } + reqArg = string(contents) + } + + // Set flags for the request functions + reqFlagApp = GetProject() + reqFlagProduct = GetProduct() + reqFlagDevice = GetDevice() + + // Perform the request + rsp, err := reqHubV0JSON(GetVerbose(), GetAPIHub(), []byte(reqArg), "", "", "", "", false, GetJson(), nil) + if err != nil { + return err + } + + // Handle output + if flagOut == "" { + if GetPretty() { + var rspo map[string]interface{} + err = note.JSONUnmarshal(rsp, &rspo) + if err != nil { + fmt.Printf("%s", rsp) + } else { + rsp, _ = note.JSONMarshalIndent(rspo, "", " ") + fmt.Printf("%s", rsp) + } + } else { + fmt.Printf("%s", rsp) + } + } else { + outfile, err := os.Create(flagOut) + if err != nil { + return err + } + defer outfile.Close() + outfile.Write(rsp) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(requestCmd) + + requestCmd.Flags().StringVarP(&flagOut, "out", "o", "", "Output filename") +} diff --git a/notehub/cmd/root.go b/notehub/cmd/root.go new file mode 100644 index 0000000..200f9aa --- /dev/null +++ b/notehub/cmd/root.go @@ -0,0 +1,164 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CLI Version - Set by ldflags during build/release +var version = "development" + +// Global flags +var ( + flagProject string + flagProduct string + flagDevice string + flagVerbose bool + flagPretty bool + flagJson bool +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "notehub", + Short: "Notehub CLI - Command-line tool for interacting with Notehub", + Long: `Notehub CLI is a command-line tool for interacting with Blues Notehub. + +It provides commands for authentication, managing projects and devices, +setting environment variables, and making API requests.`, + Version: version, + Run: func(cmd *cobra.Command, args []string) { + // If no subcommand is provided, print help (config shown via HelpFunc) + cmd.Help() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + // Initialize config before running any command + cobra.OnInitialize(initConfig) + + // Global flags available to all commands + rootCmd.PersistentFlags().StringVarP(&flagProject, "project", "p", "", "Project UID") + rootCmd.PersistentFlags().StringVar(&flagProduct, "product", "", "Product UID") + rootCmd.PersistentFlags().StringVarP(&flagDevice, "device", "d", "", "Device UID") + rootCmd.PersistentFlags().BoolVarP(&flagVerbose, "verbose", "v", false, "Display requests and responses") + rootCmd.PersistentFlags().BoolVar(&flagPretty, "pretty", false, "Pretty print JSON output") + rootCmd.PersistentFlags().BoolVar(&flagJson, "json", false, "Strip all non-JSON lines from output") + + // Bind flags to Viper (allows flags to override config file values) + viper.BindPFlag("project", rootCmd.PersistentFlags().Lookup("project")) + viper.BindPFlag("product", rootCmd.PersistentFlags().Lookup("product")) + viper.BindPFlag("device", rootCmd.PersistentFlags().Lookup("device")) + viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + viper.BindPFlag("pretty", rootCmd.PersistentFlags().Lookup("pretty")) + viper.BindPFlag("json", rootCmd.PersistentFlags().Lookup("json")) + + // Enable environment variable support (NOTEHUB_PROJECT, NOTEHUB_DEVICE, etc.) + viper.SetEnvPrefix("NOTEHUB") + viper.AutomaticEnv() +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + // Get user home directory + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %s\n", err) + os.Exit(1) + } + + // Set config file location + configDir := filepath.Join(home, ".notehub") + viper.AddConfigPath(configDir) + viper.SetConfigName("config") + viper.SetConfigType("yaml") + + // Set defaults + viper.SetDefault("hub", "notehub.io") + + // Attempt to read config file + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Config file not found; create with defaults + if err := os.MkdirAll(configDir, 0755); err != nil { + fmt.Printf("Error creating config directory: %s\n", err) + os.Exit(1) + } + // Write default config + if err := SaveConfig(); err != nil { + fmt.Printf("Error creating config file: %s\n", err) + os.Exit(1) + } + } else { + // Config file was found but another error was produced + fmt.Printf("Error reading config file: %s\n", err) + os.Exit(1) + } + } +} + +// GetCredentials returns validated credentials or exits with error +func GetCredentials() *Credentials { + credentials, err := GetHubCredentials() + if err != nil { + fmt.Printf("error getting credentials: %s\n", err) + os.Exit(1) + } + + if credentials == nil { + fmt.Printf("please sign in using 'notehub auth signin' or 'notehub auth signin-token'\n") + os.Exit(1) + } + + if err := credentials.Validate(); err != nil { + hub := GetHub() + fmt.Printf("invalid credentials for %s: %s\n", hub, err) + fmt.Printf("please use 'notehub auth signin' or 'notehub auth signin-token' to sign into Notehub\n") + os.Exit(1) + } + + return credentials +} + +// Helper functions to get flag values from Viper +// These allow flags, config file, and environment variables to work together + +func GetProject() string { + return viper.GetString("project") +} + +func GetProduct() string { + return viper.GetString("product") +} + +func GetDevice() string { + return viper.GetString("device") +} + +func GetVerbose() bool { + return viper.GetBool("verbose") +} + +func GetPretty() bool { + return viper.GetBool("pretty") +} + +func GetJson() bool { + return viper.GetBool("json") +} diff --git a/notehub/cmd/route.go b/notehub/cmd/route.go new file mode 100644 index 0000000..3819f19 --- /dev/null +++ b/notehub/cmd/route.go @@ -0,0 +1,670 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "os" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +// routeCmd represents the route command +var routeCmd = &cobra.Command{ + Use: "route", + Short: "Manage routes", + Long: `Commands for creating, updating, deleting, and viewing routes in Notehub projects.`, +} + +// routeListCmd represents the route list command +var routeListCmd = &cobra.Command{ + Use: "list", + Short: "List all routes", + Long: `List all routes in the current or specified project. + +Examples: + # List all routes + notehub route list + + # List routes in specific project + notehub route list --project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + # List with JSON output + notehub route list --json + + # List with pretty JSON + notehub route list --pretty`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + + // Get routes using V1 API: GET /v1/projects/{projectUID}/routes + var routes []map[string]interface{} + url := fmt.Sprintf("/v1/projects/%s/routes", projectUID) + err := reqHubV1(verbose, GetAPIHub(), "GET", url, nil, &routes) + if err != nil { + return fmt.Errorf("failed to list routes: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(routes, "", " ") + } else { + output, err = note.JSONMarshal(routes) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + if len(routes) == 0 { + fmt.Println("No routes found.") + return nil + } + + // Display routes in human-readable format + fmt.Printf("\nRoutes (%d):\n", len(routes)) + fmt.Printf("============\n\n") + + for _, route := range routes { + uid, _ := route["uid"].(string) + label, _ := route["label"].(string) + routeType, _ := route["type"].(string) + modified, _ := route["modified"].(string) + disabled, _ := route["disabled"].(bool) + + fmt.Printf("UID: %s\n", uid) + fmt.Printf(" Label: %s\n", label) + fmt.Printf(" Type: %s\n", routeType) + fmt.Printf(" Modified: %s\n", modified) + if disabled { + fmt.Printf(" Status: Disabled\n") + } else { + fmt.Printf(" Status: Enabled\n") + } + fmt.Println() + } + + return nil + }, +} + +// routeGetCmd represents the route get command +var routeGetCmd = &cobra.Command{ + Use: "get [route-uid-or-name]", + Short: "Get detailed information about a specific route", + Long: `Get detailed information about a specific route by UID or name. + +Examples: + # Get route by UID + notehub route get route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + # Get route by name + notehub route get "My Route" + + # Get with pretty JSON + notehub route get "My Route" --pretty`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + routeIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + + // Get metadata to resolve route name to UID if needed + appMetadata, err := appGetMetadata(verbose, false) + if err != nil { + return fmt.Errorf("failed to get project metadata: %w", err) + } + + // Find route UID (handle both UID and name) + var routeUID string + if len(routeIdentifier) > 6 && routeIdentifier[:6] == "route:" { + routeUID = routeIdentifier + } else { + // Search for route by name + for _, route := range appMetadata.Routes { + if route.Name == routeIdentifier { + routeUID = route.UID + break + } + } + if routeUID == "" { + return fmt.Errorf("route '%s' not found", routeIdentifier) + } + } + + // Get route using V1 API: GET /v1/projects/{projectUID}/routes/{routeUID} + var route map[string]interface{} + url := fmt.Sprintf("/v1/projects/%s/routes/%s", projectUID, routeUID) + err = reqHubV1(verbose, GetAPIHub(), "GET", url, nil, &route) + if err != nil { + return fmt.Errorf("failed to get route: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(route, "", " ") + } else { + output, err = note.JSONMarshal(route) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display route in human-readable format + uid, _ := route["uid"].(string) + label, _ := route["label"].(string) + routeType, _ := route["type"].(string) + modified, _ := route["modified"].(string) + disabled, _ := route["disabled"].(bool) + + fmt.Printf("\nRoute Details:\n") + fmt.Printf("==============\n\n") + fmt.Printf("UID: %s\n", uid) + fmt.Printf("Label: %s\n", label) + fmt.Printf("Type: %s\n", routeType) + fmt.Printf("Modified: %s\n", modified) + if disabled { + fmt.Printf("Status: Disabled\n") + } else { + fmt.Printf("Status: Enabled\n") + } + fmt.Println() + + // Display type-specific configuration + if routeType == "http" { + if httpConfig, ok := route["http"].(map[string]interface{}); ok { + fmt.Printf("HTTP Configuration:\n") + if httpURL, ok := httpConfig["url"].(string); ok { + fmt.Printf(" URL: %s\n", httpURL) + } + if fleets, ok := httpConfig["fleets"].([]interface{}); ok && len(fleets) > 0 { + fmt.Printf(" Fleets: %v\n", fleets) + } + if throttle, ok := httpConfig["throttle_ms"].(float64); ok { + fmt.Printf(" Throttle: %.0f ms\n", throttle) + } + if timeout, ok := httpConfig["timeout"].(float64); ok && timeout > 0 { + fmt.Printf(" Timeout: %.0f ms\n", timeout) + } + } + } + + return nil + }, +} + +// routeCreateCmd represents the route create command +var routeCreateCmd = &cobra.Command{ + Use: "create [label]", + Short: "Create a new route", + Long: `Create a new route in the current project. + +Note: Route creation requires a JSON configuration file. Use --config to specify the file. + +Examples: + # Create route from JSON file + notehub route create "My Route" --config route.json + + # Example route.json for HTTP route: + { + "label": "My HTTP Route", + "http": { + "url": "https://example.com/webhook", + "fleets": ["fleet:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"], + "throttle_ms": 100, + "timeout": 5000, + "http_headers": { + "X-Custom-Header": "value" + } + } + }`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + label := args[0] + configFile, _ := cmd.Flags().GetString("config") + + if configFile == "" { + return fmt.Errorf("--config flag is required to specify route configuration JSON file") + } + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + + // Read config file + configBytes, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var configData map[string]interface{} + err = note.JSONUnmarshal(configBytes, &configData) + if err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + // Override label if provided + configData["label"] = label + + // Marshal config to JSON + reqJSON, err := note.JSONMarshal(configData) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Create route using V1 API: POST /v1/projects/{projectUID}/routes + var route map[string]interface{} + url := fmt.Sprintf("/v1/projects/%s/routes", projectUID) + err = reqHubV1(verbose, GetAPIHub(), "POST", url, reqJSON, &route) + if err != nil { + return fmt.Errorf("failed to create route: %w", err) + } + + uid, _ := route["uid"].(string) + routeType, _ := route["type"].(string) + + fmt.Printf("\nRoute created successfully!\n\n") + fmt.Printf("UID: %s\n", uid) + fmt.Printf("Label: %s\n", label) + fmt.Printf("Type: %s\n", routeType) + fmt.Println() + + return nil + }, +} + +// routeUpdateCmd represents the route update command +var routeUpdateCmd = &cobra.Command{ + Use: "update [route-uid-or-name]", + Short: "Update an existing route", + Long: `Update an existing route by UID or name. + +Note: Route updates require a JSON configuration file. Use --config to specify the file. + +Examples: + # Update route from JSON file + notehub route update "My Route" --config route-update.json + + # Update by UID + notehub route update route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --config route-update.json + + # Example route-update.json (partial update): + { + "http": { + "url": "https://newexample.com/webhook", + "throttle_ms": 50 + } + }`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + routeIdentifier := args[0] + configFile, _ := cmd.Flags().GetString("config") + + if configFile == "" { + return fmt.Errorf("--config flag is required to specify route configuration JSON file") + } + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + + // Get metadata to resolve route name to UID if needed + appMetadata, err := appGetMetadata(verbose, false) + if err != nil { + return fmt.Errorf("failed to get project metadata: %w", err) + } + + // Find route UID (handle both UID and name) + var routeUID string + if len(routeIdentifier) > 6 && routeIdentifier[:6] == "route:" { + routeUID = routeIdentifier + } else { + // Search for route by name + for _, route := range appMetadata.Routes { + if route.Name == routeIdentifier { + routeUID = route.UID + break + } + } + if routeUID == "" { + return fmt.Errorf("route '%s' not found", routeIdentifier) + } + } + + // Read config file + configBytes, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var configData map[string]interface{} + err = note.JSONUnmarshal(configBytes, &configData) + if err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + // Marshal config to JSON + reqJSON, err := note.JSONMarshal(configData) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Update route using V1 API: PUT /v1/projects/{projectUID}/routes/{routeUID} + var route map[string]interface{} + url := fmt.Sprintf("/v1/projects/%s/routes/%s", projectUID, routeUID) + err = reqHubV1(verbose, GetAPIHub(), "PUT", url, reqJSON, &route) + if err != nil { + return fmt.Errorf("failed to update route: %w", err) + } + + label, _ := route["label"].(string) + routeType, _ := route["type"].(string) + + fmt.Printf("\nRoute updated successfully!\n\n") + fmt.Printf("UID: %s\n", routeUID) + fmt.Printf("Label: %s\n", label) + fmt.Printf("Type: %s\n", routeType) + fmt.Println() + + return nil + }, +} + +// routeDeleteCmd represents the route delete command +var routeDeleteCmd = &cobra.Command{ + Use: "delete [route-uid-or-name]", + Short: "Delete a route", + Long: `Delete a route by UID or name. + +Examples: + # Delete route by UID + notehub route delete route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + # Delete route by name + notehub route delete "My Route"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + routeIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + + // Get metadata to resolve route name to UID if needed + appMetadata, err := appGetMetadata(verbose, false) + if err != nil { + return fmt.Errorf("failed to get project metadata: %w", err) + } + + // Find route UID (handle both UID and name) + var routeUID string + var routeName string + if len(routeIdentifier) > 6 && routeIdentifier[:6] == "route:" { + routeUID = routeIdentifier + // Try to find name for better output + for _, route := range appMetadata.Routes { + if route.UID == routeUID { + routeName = route.Name + break + } + } + } else { + // Search for route by name + routeName = routeIdentifier + for _, route := range appMetadata.Routes { + if route.Name == routeIdentifier { + routeUID = route.UID + break + } + } + if routeUID == "" { + return fmt.Errorf("route '%s' not found", routeIdentifier) + } + } + + // Delete route using V1 API: DELETE /v1/projects/{projectUID}/routes/{routeUID} + url := fmt.Sprintf("/v1/projects/%s/routes/%s", projectUID, routeUID) + err = reqHubV1(verbose, GetAPIHub(), "DELETE", url, nil, nil) + if err != nil { + return fmt.Errorf("failed to delete route: %w", err) + } + + fmt.Printf("\nRoute deleted successfully!\n\n") + fmt.Printf("UID: %s\n", routeUID) + if routeName != "" { + fmt.Printf("Label: %s\n", routeName) + } + fmt.Println() + + return nil + }, +} + +// routeLogsCmd represents the route logs command +var routeLogsCmd = &cobra.Command{ + Use: "logs [route-uid-or-name]", + Short: "Get logs for a specific route", + Long: `Get logs for a specific route by UID or name. + +Examples: + # Get logs for route + notehub route logs "My Route" + + # Get logs by UID + notehub route logs route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + # Get logs with pagination + notehub route logs "My Route" --page-size 100 --page-num 1 + + # Filter logs by device + notehub route logs "My Route" --device dev:864475046552567 + + # Get logs with JSON output + notehub route logs "My Route" --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validates and exits if not authenticated + + routeIdentifier := args[0] + + // Get project UID (from config or --project flag) + projectUID := GetProject() + if projectUID == "" { + return fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + verbose := GetVerbose() + pageSize, _ := cmd.Flags().GetInt("page-size") + pageNum, _ := cmd.Flags().GetInt("page-num") + deviceUID, _ := cmd.Flags().GetString("device") + + // Get metadata to resolve route name to UID if needed + appMetadata, err := appGetMetadata(verbose, false) + if err != nil { + return fmt.Errorf("failed to get project metadata: %w", err) + } + + // Find route UID (handle both UID and name) + var routeUID string + if len(routeIdentifier) > 6 && routeIdentifier[:6] == "route:" { + routeUID = routeIdentifier + } else { + // Search for route by name + for _, route := range appMetadata.Routes { + if route.Name == routeIdentifier { + routeUID = route.UID + break + } + } + if routeUID == "" { + return fmt.Errorf("route '%s' not found", routeIdentifier) + } + } + + // Build URL with query parameters + url := fmt.Sprintf("/v1/projects/%s/routes/%s/route-logs", projectUID, routeUID) + + // Add query parameters + firstParam := true + if pageSize > 0 { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += fmt.Sprintf("pageSize=%d", pageSize) + } + if pageNum > 0 { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += fmt.Sprintf("pageNum=%d", pageNum) + } + if deviceUID != "" { + if firstParam { + url += "?" + firstParam = false + } else { + url += "&" + } + url += fmt.Sprintf("deviceUID=%s", deviceUID) + } + + // Get route logs using V1 API: GET /v1/projects/{projectUID}/routes/{routeUID}/route-logs + var logs map[string]interface{} + err = reqHubV1(verbose, GetAPIHub(), "GET", url, nil, &logs) + if err != nil { + return fmt.Errorf("failed to get route logs: %w", err) + } + + // Handle JSON output + if GetJson() || GetPretty() { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(logs, "", " ") + } else { + output, err = note.JSONMarshal(logs) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Printf("%s\n", output) + return nil + } + + // Display logs in human-readable format + logEntries, _ := logs["logs"].([]interface{}) + hasMore, _ := logs["has_more"].(bool) + + if len(logEntries) == 0 { + fmt.Println("No logs found.") + return nil + } + + fmt.Printf("\nRoute Logs (%d entries):\n", len(logEntries)) + fmt.Printf("========================\n\n") + + for i, entry := range logEntries { + if logMap, ok := entry.(map[string]interface{}); ok { + when, _ := logMap["when"].(string) + deviceID, _ := logMap["device_uid"].(string) + status, _ := logMap["status"].(string) + message, _ := logMap["message"].(string) + + fmt.Printf("%d. %s\n", i+1, when) + if deviceID != "" { + fmt.Printf(" Device: %s\n", deviceID) + } + if status != "" { + fmt.Printf(" Status: %s\n", status) + } + if message != "" { + fmt.Printf(" Message: %s\n", message) + } + fmt.Println() + } + } + + if hasMore { + fmt.Printf("More logs available. Use --page-num %d to see next page.\n", pageNum+1) + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(routeCmd) + routeCmd.AddCommand(routeListCmd) + routeCmd.AddCommand(routeGetCmd) + routeCmd.AddCommand(routeCreateCmd) + routeCmd.AddCommand(routeUpdateCmd) + routeCmd.AddCommand(routeDeleteCmd) + routeCmd.AddCommand(routeLogsCmd) + + // Add flags for route create + routeCreateCmd.Flags().String("config", "", "Path to JSON configuration file (required)") + routeCreateCmd.MarkFlagRequired("config") + + // Add flags for route update + routeUpdateCmd.Flags().String("config", "", "Path to JSON configuration file (required)") + routeUpdateCmd.MarkFlagRequired("config") + + // Add flags for route logs + routeLogsCmd.Flags().Int("page-size", 50, "Number of logs to return per page") + routeLogsCmd.Flags().Int("page-num", 1, "Page number to retrieve") + routeLogsCmd.Flags().String("device", "", "Filter logs by device UID") +} diff --git a/notehub/trace.go b/notehub/cmd/trace.go similarity index 67% rename from notehub/trace.go rename to notehub/cmd/trace.go index 67d85bf..f0ce0d5 100644 --- a/notehub/trace.go +++ b/notehub/cmd/trace.go @@ -2,7 +2,7 @@ // Use of this source code is governed by licenses granted by the // copyright holder including that found in the LICENSE file. -package main +package cmd import ( "bufio" @@ -11,9 +11,40 @@ import ( "regexp" "strings" - "github.com/blues/note-cli/lib" + "github.com/spf13/cobra" ) +// traceCmd represents the trace command +var traceCmd = &cobra.Command{ + Use: "trace", + Short: "Enter interactive trace mode", + Long: `Enter an interactive trace mode to send requests to Notehub. + +In trace mode, you can: + - Set project, product, and device context + - Send JSON requests + - Make HTTPS GET, POST, PUT, DELETE requests + - Ping the Notehub + - Type ? for help + +Example: + notehub trace --project app:xxxx --device dev:yyyy`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + // Set initial context + reqFlagApp = GetProject() + reqFlagProduct = GetProduct() + reqFlagDevice = GetDevice() + + return traceMode() + }, +} + +func init() { + rootCmd.AddCommand(traceCmd) +} + // Command definitions type cmdDef struct { Command string @@ -21,7 +52,8 @@ type cmdDef struct { } func validCommands() []cmdDef { - return []cmdDef{{"product", "set productUID for requests made in this session"}, + return []cmdDef{ + {"product", "set productUID for requests made in this session"}, {"project", "set projectUID (appUID) for requests made in this session"}, {"device", "set deviceUID for requests made in this session"}, {"hub", "set notehub domain for requests made in this session"}, @@ -35,8 +67,7 @@ func validCommands() []cmdDef { } // Enter a diagnostic trace mode -func trace() error { - +func traceMode() error { // Create a scanner to watch stdin scanner := bufio.NewScanner(os.Stdin) var cmd string @@ -58,7 +89,7 @@ traceloop: // Process JSON requests if strings.HasPrefix(cmd, "{") { - _, err := reqHubV0JSON(true, lib.ConfigAPIHub(), []byte(cmd), "", "", "", "", false, false, nil) + _, err := reqHubV0JSON(true, GetAPIHub(), []byte(cmd), "", "", "", "", false, false, nil) if err != nil { fmt.Printf("error: %s\n", err) } @@ -66,21 +97,21 @@ traceloop: } // Create clean IDs to work with in the commands - cleanProduct := flagProduct + cleanProduct := reqFlagProduct if cleanProduct != "" && !strings.HasPrefix(cleanProduct, "product:") { - cleanProduct = "product:" + flagProduct + cleanProduct = "product:" + reqFlagProduct } - cleanApp := flagApp + cleanApp := reqFlagApp if !strings.HasPrefix(cleanApp, "app:") { if cleanApp == "" { cleanApp = cleanProduct } else { - cleanApp = "app:" + flagApp + cleanApp = "app:" + reqFlagApp } } - cleanDevice := flagDevice + cleanDevice := reqFlagDevice if !strings.HasPrefix(cleanDevice, "dev:") { - cleanDevice = "dev:" + flagDevice + cleanDevice = "dev:" + reqFlagDevice } cmdAfter0 = strings.Replace(cmdAfter0, "{productUID}", cleanProduct, -1) cmdAfter0 = strings.Replace(cmdAfter0, "{projectUID}", cleanApp, -1) @@ -99,9 +130,9 @@ traceloop: if args[1] == "-" { args[1] = "" } - flagProduct = args[1] + reqFlagProduct = args[1] } - fmt.Printf("productUID is %s\n", flagProduct) + fmt.Printf("productUID is %s\n", reqFlagProduct) case "project": fallthrough @@ -110,28 +141,28 @@ traceloop: if args[1] == "-" { args[1] = "" } - flagApp = args[1] + reqFlagApp = args[1] } - fmt.Printf("projectUID is %s\n", flagApp) + fmt.Printf("projectUID is %s\n", reqFlagApp) case "device": if args[1] != "" { if args[1] == "-" { args[1] = "" } - flagDevice = args[1] + reqFlagDevice = args[1] } - fmt.Printf("deviceUID is %s\n", flagDevice) + fmt.Printf("deviceUID is %s\n", reqFlagDevice) case "hub": if args[1] != "" { if args[1] == "-" { args[1] = "" } - config, _ := lib.GetConfig() - config.Hub = args[1] + SetHub(args[1]) + SaveConfig() } - fmt.Printf("hub is %s\n", flagApp) + fmt.Printf("hub is %s\n", GetHub()) case "get": fallthrough @@ -158,20 +189,20 @@ traceloop: } // Perform the transaction - _, err := reqHubV1JSON(true, lib.ConfigAPIHub(), args[0], url, bodyJSON) + _, err := reqHubV1JSON(true, GetAPIHub(), args[0], url, bodyJSON) if err != nil { fmt.Printf("error: %s\n", err) return err } case "ping": - _, err := reqHubV1JSON(true, lib.ConfigAPIHub(), "GET", "/ping", nil) + _, err := reqHubV1JSON(true, GetAPIHub(), "GET", "/ping", nil) if err != nil { fmt.Printf("error: %s\n", err) return err } if cleanApp != "" { url := "/v1/products/" + cleanApp + "/products" - _, err = reqHubV1JSON(true, lib.ConfigAPIHub(), "GET", url, nil) + _, err = reqHubV1JSON(true, GetAPIHub(), "GET", url, nil) if err != nil { fmt.Printf("error: %s\n", err) return err diff --git a/notehub/cmd/upload.go b/notehub/cmd/upload.go new file mode 100644 index 0000000..ddbcda0 --- /dev/null +++ b/notehub/cmd/upload.go @@ -0,0 +1,168 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var ( + flagType string + flagTags string + flagNotes string + flagOverwrite bool +) + +// uploadCmd represents the upload command +var uploadCmd = &cobra.Command{ + Use: "upload [file]", + Short: "Upload firmware to Notehub", + Long: `Upload host or notecard firmware to Notehub using the V1 API. + +The firmware type must be specified as either 'host' or 'notecard' using the --type flag. + +Example: + notehub upload firmware.bin --type host --project app:xxxx + notehub upload notecard-fw.bin --type notecard --product com.company:product --notes "Bug fixes"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + filename := args[0] + + // Determine project UID + projectUID := GetProject() + if projectUID == "" { + product := GetProduct() + if product != "" { + projectUID = product + } + } + if projectUID == "" { + return fmt.Errorf("--project or --product flag is required") + } + + // Validate firmware type + if flagType == "" { + return fmt.Errorf("--type flag is required (must be 'host' or 'notecard')") + } + if flagType != "host" && flagType != "notecard" { + return fmt.Errorf("--type must be either 'host' or 'notecard', got: %s", flagType) + } + + // Upload using V1 API + err := uploadFirmwareV1(projectUID, flagType, filename, flagNotes, GetVerbose()) + if err != nil { + return err + } + + fmt.Printf("Successfully uploaded %s firmware: %s\n", flagType, filename) + return nil + }, +} + +func init() { + rootCmd.AddCommand(uploadCmd) + + uploadCmd.Flags().StringVar(&flagType, "type", "", "Firmware type: 'host' or 'notecard' (required)") + uploadCmd.Flags().StringVar(&flagNotes, "notes", "", "Notes describing the firmware") + uploadCmd.MarkFlagRequired("type") +} + +// uploadFirmwareV1 uploads firmware using the V1 API +// PUT /v1/projects/{projectOrProductUID}/firmware/{firmwareType}/{filename} +func uploadFirmwareV1(projectUID, firmwareType, filename, notes string, verbose bool) error { + // Read the file + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Get file info + fileInfo, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + // Create multipart form data + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add file + part, err := writer.CreateFormFile("file", filepath.Base(filename)) + if err != nil { + return fmt.Errorf("failed to create form file: %w", err) + } + + _, err = io.Copy(part, file) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + // Close the writer to set the terminating boundary + err = writer.Close() + if err != nil { + return fmt.Errorf("failed to close writer: %w", err) + } + + // Build URL + hub := GetAPIHub() + url := fmt.Sprintf("https://%s/v1/projects/%s/firmware/%s/%s", + hub, projectUID, firmwareType, filepath.Base(filename)) + + // Add query parameters if provided + if notes != "" { + url += fmt.Sprintf("?notes=%s", notes) + } + + // Create HTTP request + req, err := http.NewRequest("PUT", url, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("User-Agent", "notehub-client") + + // Add authentication + err = AddAuthenticationHeader(req) + if err != nil { + return fmt.Errorf("failed to set auth header: %w", err) + } + + if verbose { + fmt.Printf("PUT %s (file size: %d bytes)\n", url, fileInfo.Size()) + } + + // Send request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to upload: %w", err) + } + defer resp.Body.Close() + + // Check response + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + responseBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + if verbose { + fmt.Printf("Upload successful (status: %d)\n", resp.StatusCode) + } + + return nil +} diff --git a/notehub/cmd/vars.go b/notehub/cmd/vars.go new file mode 100644 index 0000000..402f4fb --- /dev/null +++ b/notehub/cmd/vars.go @@ -0,0 +1,171 @@ +// Copyright 2024 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/blues/note-go/note" + "github.com/spf13/cobra" +) + +var ( + flagScope string + flagSn string +) + +// varsCmd represents the vars command +var varsCmd = &cobra.Command{ + Use: "vars", + Short: "Manage environment variables", + Long: `Commands for getting and setting environment variables for devices and fleets.`, +} + +// varsGetCmd represents the vars get command +var varsGetCmd = &cobra.Command{ + Use: "get", + Short: "Get environment variables", + Long: `Get environment variables for devices or fleets. + +Scope can be: + - A device UID (dev:xxxx) + - A fleet UID (fleet:xxxx) + - A fleet name or pattern (e.g., "production" or "prod*") + - @fleetname to get all devices in a fleet + - @ to get all devices in the project + - A comma-separated list of any of the above + - @filename to read scope from a file`, + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + if flagScope == "" { + return fmt.Errorf("use --scope to specify device(s) or fleet(s)") + } + + verbose := GetVerbose() + appMetadata, scopeDevices, scopeFleets, err := appGetScope(flagScope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 && len(scopeFleets) == 0 { + return fmt.Errorf("no devices or fleets found within the specified scope") + } + + if len(scopeDevices) != 0 && len(scopeFleets) != 0 { + return fmt.Errorf("scope may include devices or fleets but not both") + } + + var vars map[string]Vars + if len(scopeDevices) != 0 { + vars, err = varsGetFromDevices(appMetadata, scopeDevices, verbose) + } else if len(scopeFleets) != 0 { + vars, err = varsGetFromFleets(appMetadata, scopeFleets, verbose) + } + if err != nil { + return err + } + + var varsJSON []byte + if GetPretty() { + varsJSON, err = note.JSONMarshalIndent(vars, "", " ") + } else { + varsJSON, err = note.JSONMarshal(vars) + } + if err != nil { + return err + } + + fmt.Printf("%s\n", varsJSON) + return nil + }, +} + +// varsSetCmd represents the vars set command +var varsSetCmd = &cobra.Command{ + Use: "set [json]", + Short: "Set environment variables", + Long: `Set environment variables for devices or fleets. + +The JSON argument can be a JSON object or @filename to read from a file. + +Example: + notehub vars set --scope dev:xxxx '{"VAR1":"value1","VAR2":"value2"}' + notehub vars set --scope @production @vars.json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + GetCredentials() // Validate credentials + + if flagScope == "" { + return fmt.Errorf("use --scope to specify device(s) or fleet(s)") + } + + verbose := GetVerbose() + appMetadata, scopeDevices, scopeFleets, err := appGetScope(flagScope, verbose) + if err != nil { + return err + } + + if len(scopeDevices) == 0 && len(scopeFleets) == 0 { + return fmt.Errorf("no devices or fleets found within the specified scope") + } + + if len(scopeDevices) != 0 && len(scopeFleets) != 0 { + return fmt.Errorf("scope may include devices or fleets but not both") + } + + // Parse template + template := Vars{} + varsArg := args[0] + if strings.HasPrefix(varsArg, "@") { + var templateJSON []byte + templateJSON, err = os.ReadFile(strings.TrimPrefix(varsArg, "@")) + if err != nil { + return err + } + err = note.JSONUnmarshal(templateJSON, &template) + } else { + err = note.JSONUnmarshal([]byte(varsArg), &template) + } + if err != nil { + return err + } + + var vars map[string]Vars + if len(scopeDevices) != 0 { + vars, err = varsSetFromDevices(appMetadata, scopeDevices, template, verbose) + } else if len(scopeFleets) != 0 { + vars, err = varsSetFromFleets(appMetadata, scopeFleets, template, verbose) + } + if err != nil { + return err + } + + var varsJSON []byte + if GetPretty() { + varsJSON, err = note.JSONMarshalIndent(vars, "", " ") + } else { + varsJSON, err = note.JSONMarshal(vars) + } + if err != nil { + return err + } + + fmt.Printf("%s\n", varsJSON) + return nil + }, +} + +func init() { + rootCmd.AddCommand(varsCmd) + varsCmd.AddCommand(varsGetCmd) + varsCmd.AddCommand(varsSetCmd) + + // Flags for vars commands + varsGetCmd.Flags().StringVarP(&flagScope, "scope", "s", "", "Device/fleet scope (required)") + varsSetCmd.Flags().StringVarP(&flagScope, "scope", "s", "", "Device/fleet scope (required)") +} diff --git a/notehub/doc/CLI.md b/notehub/doc/CLI.md new file mode 100644 index 0000000..11e8704 --- /dev/null +++ b/notehub/doc/CLI.md @@ -0,0 +1,914 @@ +# Notehub CLI Documentation + +The Notehub CLI is a command-line tool for interacting with Blues Notehub. It provides commands for authentication, managing projects, devices, fleets, products, and firmware updates. + +## Table of Contents + +- [Global Flags](#global-flags) +- [Device Scoping](#device-scoping) +- [Authentication](#authentication) +- [Project Management](#project-management) +- [Product Management](#product-management) +- [Fleet Management](#fleet-management) +- [Route Management](#route-management) +- [Device Management](#device-management) +- [Firmware Updates (DFU)](#firmware-updates-dfu) +- [Configuration](#configuration) +- [Examples](#examples) +- [Tips](#tips) +- [Error Handling](#error-handling) +- [Getting Help](#getting-help) + +--- + +## Global Flags + +These flags are available for all commands: + +| Flag | Short | Description | +| ----------- | ----- | --------------------------------------- | +| `--project` | `-p` | Project UID to use for the command | +| `--product` | | Product UID to use for the command | +| `--device` | `-d` | Device UID to use for the command | +| `--verbose` | `-v` | Display requests and responses | +| `--json` | | Output only JSON (strip non-JSON lines) | +| `--pretty` | | Pretty print JSON output | + +--- + +## Device Scoping + +Many commands support flexible device scoping to target one or more devices. The scope syntax is consistent across all device and firmware update commands. + +### Scope Formats + +| Format | Description | Example | +|--------|-------------|---------| +| `dev:xxxx` | Single device by UID | `dev:864475046552567` | +| `imei:xxxx` | Single device by IMEI | `imei:123456789012345` | +| `fleet:xxxx` | All devices in fleet (by UID) | `fleet:abc123...` | +| `production` | All devices in named fleet | `production` | +| `prod*` | Fleet wildcard matching | `prod*` (matches prod-east, prod-west) | +| `@fleet-name` | Fleet indirection (explicit) | `@production` | +| `@` | All devices in project | `@` | +| `@devices.txt` | Device UIDs from file (one per line) | `@devices.txt` | +| `dev:a,dev:b` | Multiple scopes (comma-separated) | `dev:111,dev:222` | + +### Fleet Name vs Fleet Indirection + +Both `production` and `@production` target all devices in the "production" fleet: + +- **Fleet name** (`production`) - Direct, supports wildcards (`prod*`) +- **Fleet indirection** (`@production`) - Explicit, clearer intent + +Use whichever style you prefer - they work identically for single fleet names. + +### File-Based Scoping + +Create a text file with device UIDs (one per line): + +``` +dev:864475046552567 +dev:864475046552568 +dev:864475046552569 +``` + +Then reference it with `@devices.txt` in any command that accepts scope. + +--- + +## Authentication + +Commands for signing in, signing out, and managing authentication tokens. + +### `notehub auth signin` + +Sign in to Notehub using browser-based OAuth2 flow. + +```bash +# Basic signin +notehub auth signin + +# Sign in and automatically set a project +notehub auth signin --set-project "My Project" +notehub auth signin --set-project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +**Flags:** +- `--set-project`: Automatically set project after signin (name or UID) + +### `notehub auth signin-token [token]` + +Sign in to Notehub using a personal access token. + +```bash +# Sign in with a personal access token +notehub auth signin-token your-personal-access-token + +# Sign in with token and set project +notehub auth signin-token your-token --set-project "My Project" +``` + +**Flags:** +- `--set-project`: Automatically set project after signin (name or UID) + +### `notehub auth signout` + +Sign out of Notehub and remove stored credentials. + +```bash +notehub auth signout +``` + +### `notehub auth token` + +Display the current authentication token. + +```bash +notehub auth token +``` + +--- + +## Project Management + +Commands for listing and selecting Notehub projects. + +### `notehub project list` + +List all projects for the authenticated user. + +```bash +# List all projects +notehub project list + +# List with JSON output +notehub project list --json + +# List with pretty JSON +notehub project list --pretty +``` + +### `notehub project get [project-name-or-uid]` + +Get detailed information about a specific project. If no project is specified, uses the active project. + +```bash +# Get information about active project +notehub project get + +# Get information about specific project by UID +notehub project get app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Get information about specific project by name +notehub project get "My Project" + +# Get with JSON output +notehub project get --json + +# Get with pretty JSON +notehub project get --pretty +``` + +### `notehub project set [project-name-or-uid]` + +Set the active project in the configuration. + +```bash +# Set project by name +notehub project set "My Project" + +# Set project by UID +notehub project set app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +### `notehub project clear` + +Clear the active project from the configuration. + +```bash +notehub project clear +``` + +--- + +## Product Management + +Commands for listing and managing products in Notehub projects. + +### `notehub product list` + +List all products in the current or specified project. + +```bash +# List products in current project +notehub product list + +# List products in specific project +notehub product list --project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# List with JSON output +notehub product list --json + +# List with pretty JSON +notehub product list --pretty +``` + +### `notehub product get [product-uid-or-name]` + +Get detailed information about a specific product. + +```bash +# Get product by name +notehub product get "My Product" + +# Get product by UID +notehub product get com.company.user:product-name + +# Get with JSON output +notehub product get "My Product" --json +``` + +--- + +## Fleet Management + +Commands for listing and managing fleets in Notehub projects. + +### `notehub fleet list` + +List all fleets in the current or specified project. + +```bash +# List all fleets +notehub fleet list + +# List fleets in specific project +notehub fleet list --project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# List with JSON output +notehub fleet list --json +``` + +### `notehub fleet get [fleet-uid-or-name]` + +Get detailed information about a specific fleet. + +```bash +# Get fleet by name +notehub fleet get production + +# Get fleet by UID +notehub fleet get fleet:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Get with pretty JSON +notehub fleet get production --pretty +``` + +### `notehub fleet create [name]` + +Create a new fleet in the current project. + +```bash +# Create a simple fleet +notehub fleet create production + +# Create fleet with smart rule +notehub fleet create production --smart-rule '$contains(environment_variables.stage, "prod")' + +# Create fleet with connectivity assurance enabled +notehub fleet create production --connectivity-assurance +``` + +**Flags:** +- `--smart-rule`: JSONata expression for dynamic fleet membership +- `--connectivity-assurance`: Enable connectivity assurance for this fleet + +### `notehub fleet update [fleet-uid-or-name]` + +Update a fleet's properties. + +```bash +# Update fleet name +notehub fleet update production --name production-fleet + +# Update smart rule +notehub fleet update production --smart-rule '$contains(tags, "prod")' + +# Enable connectivity assurance +notehub fleet update production --connectivity-assurance true + +# Set watchdog timer +notehub fleet update production --watchdog-mins 60 +``` + +**Flags:** +- `--name`: New name for the fleet +- `--smart-rule`: JSONata expression for dynamic fleet membership +- `--connectivity-assurance`: Enable or disable connectivity assurance +- `--watchdog-mins`: Watchdog timer in minutes (0 to disable) + +### `notehub fleet delete [fleet-uid-or-name]` + +Delete a fleet from the current project. + +```bash +# Delete fleet by name +notehub fleet delete production + +# Delete fleet by UID +notehub fleet delete fleet:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +--- + +## Route Management + +Commands for creating, updating, deleting, and viewing routes in Notehub projects. + +### `notehub route list` + +List all routes in the current or specified project. + +```bash +# List all routes +notehub route list + +# List routes in specific project +notehub route list --project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# List with JSON output +notehub route list --json + +# List with pretty JSON +notehub route list --pretty +``` + +### `notehub route get [route-uid-or-name]` + +Get detailed information about a specific route. + +```bash +# Get route by UID +notehub route get route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Get route by name +notehub route get "My Route" + +# Get with pretty JSON +notehub route get "My Route" --pretty +``` + +### `notehub route create [label]` + +Create a new route in the current project. Requires a JSON configuration file. + +```bash +# Create route from JSON file +notehub route create "My Route" --config route.json +``` + +**Example route.json for HTTP route:** + +```json +{ + "label": "My HTTP Route", + "http": { + "url": "https://example.com/webhook", + "fleets": ["fleet:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"], + "throttle_ms": 100, + "timeout": 5000, + "http_headers": { + "X-Custom-Header": "value" + } + } +} +``` + +**Flags:** + +- `--config`: Path to JSON configuration file (required) + +### `notehub route update [route-uid-or-name]` + +Update an existing route. Requires a JSON configuration file. + +```bash +# Update route from JSON file +notehub route update "My Route" --config route-update.json + +# Update by UID +notehub route update route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --config route-update.json +``` + +**Example route-update.json (partial update):** + +```json +{ + "http": { + "url": "https://newexample.com/webhook", + "throttle_ms": 50 + } +} +``` + +**Flags:** + +- `--config`: Path to JSON configuration file (required) + +### `notehub route delete [route-uid-or-name]` + +Delete a route from the current project. + +```bash +# Delete route by UID +notehub route delete route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Delete route by name +notehub route delete "My Route" +``` + +### `notehub route logs [route-uid-or-name]` + +Get logs for a specific route, showing delivery attempts, errors, and status information. + +```bash +# Get logs for route +notehub route logs "My Route" + +# Get logs by UID +notehub route logs route:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Get logs with pagination +notehub route logs "My Route" --page-size 100 --page-num 1 + +# Filter logs by device +notehub route logs "My Route" --device dev:864475046552567 + +# Get logs with JSON output +notehub route logs "My Route" --json +``` + +**Flags:** + +- `--page-size`: Number of logs to return per page (default: 50) +- `--page-num`: Page number to retrieve (default: 1) +- `--device`: Filter logs by device UID + +--- + +## Device Management + +Commands for listing and managing devices in Notehub projects. + +### `notehub device list` + +List all devices in the current or specified project. + +```bash +# List all devices +notehub device list + +# List devices in specific project +notehub device list --project app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# List with JSON output +notehub device list --json +``` + +### `notehub device enable [scope]` + +Enable one or more devices, allowing them to communicate with Notehub. + +See [Device Scoping](#device-scoping) for all supported scope formats. + +```bash +# Enable a single device +notehub device enable dev:864475046552567 + +# Enable all devices in a fleet (by name) +notehub device enable production + +# Enable all devices in a fleet (explicit indirection) +notehub device enable @production + +# Enable all devices in project +notehub device enable @ + +# Enable devices from a file +notehub device enable @devices.txt + +# Enable multiple devices +notehub device enable dev:111,dev:222,dev:333 + +# Enable devices with wildcard fleet matching +notehub device enable prod* +``` + +### `notehub device disable [scope]` + +Disable one or more devices, preventing them from communicating with Notehub. + +See [Device Scoping](#device-scoping) for all supported scope formats. + +```bash +# Disable a single device +notehub device disable dev:864475046552567 + +# Disable all devices in a fleet +notehub device disable @production + +# Disable all devices in project +notehub device disable @ + +# Disable devices from a file +notehub device disable @devices.txt +``` + +### `notehub device move [scope] [fleet-uid-or-name]` + +Move one or more devices to a fleet. + +See [Device Scoping](#device-scoping) for all supported scope formats. + +```bash +# Move a single device to a fleet +notehub device move dev:864475046552567 production + +# Move a device to a fleet by UID +notehub device move dev:864475046552567 fleet:xxxx + +# Move all devices from one fleet to another +notehub device move @old-fleet new-fleet + +# Move devices from a file to a fleet +notehub device move @devices.txt production + +# Move multiple devices to a fleet +notehub device move dev:111,dev:222 production +``` + +### `notehub device health [device-uid]` + +Get the health log for a specific device, showing boot events, DFU completions, and other health-related information. + +```bash +# Get health log for a device +notehub device health dev:864475046552567 + +# Get health log with JSON output +notehub device health dev:864475046552567 --json + +# Get health log with pretty JSON +notehub device health dev:864475046552567 --pretty +``` + +### `notehub device session [device-uid]` + +Get the session log for a specific device, showing connection history, network information, and session statistics. + +```bash +# Get session log for a device +notehub device session dev:864475046552567 + +# Get session log with JSON output +notehub device session dev:864475046552567 --json + +# Get session log with pretty JSON +notehub device session dev:864475046552567 --pretty +``` + +--- + +## Firmware Updates (DFU) + +Commands for scheduling and managing firmware updates for Notecards and host MCUs. + +### `notehub dfu list` + +List all firmware files available in the current project. + +```bash +# List all firmware files +notehub dfu list + +# List only host firmware +notehub dfu list --type host + +# List only notecard firmware +notehub dfu list --type notecard + +# Filter by version +notehub dfu list --version "1.2.3" + +# Filter by target +notehub dfu list --target "stm32" + +# List with pretty JSON +notehub dfu list --pretty +``` + +**Flags:** +- `--type`: Filter by firmware type (host or notecard) +- `--product`: Filter by product UID +- `--version`: Filter by version +- `--target`: Filter by target device +- `--filename`: Filter by filename + +### `notehub dfu update [firmware-type] [filename] [scope]` + +Schedule a firmware update for devices. Firmware type must be either `host` or `notecard`. + +See [Device Scoping](#device-scoping) for all supported scope formats. + +**Additional Filter Flags:** + +These filters narrow down the scope further: + +- `--tag`: Filter by device tags (comma-separated) +- `--serial`: Filter by serial numbers (comma-separated) +- `--location`: Filter by location +- `--notecard-firmware`: Filter by Notecard firmware version +- `--host-firmware`: Filter by host firmware version +- `--product`: Filter by product UID +- `--sku`: Filter by SKU + +```bash +# Schedule notecard firmware update for a specific device +notehub dfu update notecard notecard-6.2.1.bin dev:864475046552567 + +# Schedule host firmware update for all devices in a fleet +notehub dfu update host app-v1.2.3.bin @production + +# Schedule update for multiple devices +notehub dfu update notecard notecard-6.2.1.bin dev:111,dev:222,dev:333 + +# Schedule update for all devices in project +notehub dfu update notecard notecard-6.2.1.bin @ + +# Schedule update for devices from a file +notehub dfu update host app-v1.2.3.bin @devices.txt + +# Schedule update for fleet with wildcard +notehub dfu update notecard notecard-6.2.1.bin prod* + +# Schedule update with additional SKU filter +notehub dfu update notecard notecard-6.2.1.bin @production --sku NOTE-WBEX + +# Schedule update with additional location filter +notehub dfu update host app-v1.2.3.bin @production --location "San Francisco" + +# Combine scope with multiple additional filters +notehub dfu update notecard notecard-6.2.1.bin @production --tag outdoor --sku NOTE-WBEX +``` + +### `notehub dfu cancel [firmware-type] [scope]` + +Cancel pending firmware updates for devices. Firmware type must be either `host` or `notecard`. + +See [Device Scoping](#device-scoping) for all supported scope formats. + +**Additional Filter Flags:** + +- `--tag`: Filter by device tags (comma-separated) +- `--serial`: Filter by serial numbers (comma-separated) + +```bash +# Cancel notecard firmware update for a specific device +notehub dfu cancel notecard dev:864475046552567 + +# Cancel host firmware updates for all devices in a fleet +notehub dfu cancel host @production + +# Cancel updates for multiple devices +notehub dfu cancel notecard dev:111,dev:222,dev:333 + +# Cancel updates for all devices in project +notehub dfu cancel notecard @ + +# Cancel updates for devices from a file +notehub dfu cancel host @devices.txt + +# Cancel updates with additional tag filter +notehub dfu cancel host @production --tag outdoor +``` + +--- + +## Configuration + +The CLI stores configuration in `~/.notehub/config.yaml`, including: + +- Active project UID +- API hub URL (default: notehub.io) +- Authentication credentials (stored securely) + +You can also use environment variables: + +```bash +export NOTEHUB_PROJECT=app:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +export NOTEHUB_VERBOSE=true +``` + +--- + +## Examples + +### Common Workflows + +**1. Initial Setup** + +```bash +# Sign in to Notehub +notehub auth signin + +# List available projects +notehub project list + +# Set active project +notehub project set "My Project" + +# List devices in project +notehub device list +``` + +**2. Managing a Fleet** + +```bash +# Create a production fleet +notehub fleet create production + +# Move devices to the fleet +notehub device move dev:123456 production +notehub device move dev:789012 production + +# Or move multiple devices at once +notehub device move dev:123456,dev:789012 production + +# View fleet details +notehub fleet get production + +# Enable all devices in the fleet +notehub device enable @production +``` + +**3. Firmware Update Workflow** + +```bash +# List available firmware +notehub dfu list --type notecard + +# Schedule firmware update for a fleet +notehub dfu update notecard notecard-6.2.1.bin @production + +# Schedule update with additional filtering +notehub dfu update notecard notecard-6.2.1.bin @production --sku NOTE-WBEX + +# Check device health after update +notehub device health dev:864475046552567 + +# Cancel pending updates if needed +notehub dfu cancel notecard @production +``` + +**4. Device Troubleshooting** + +```bash +# Check device health +notehub device health dev:864475046552567 + +# View session history +notehub device session dev:864475046552567 + +# Get verbose output for debugging +notehub device session dev:864475046552567 --verbose +``` + +**5. Using File-Based Scoping** + +```bash +# Create a file with device UIDs +cat > devices.txt < Date: Fri, 5 Dec 2025 11:30:59 +0000 Subject: [PATCH 2/4] fix: dependencies --- .github/workflows/audit.yml | 2 +- go.mod | 40 ++----------------------------------- go.sum | 36 ++++----------------------------- 3 files changed, 7 insertions(+), 71 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 0e806d3..0bfe2b1 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -18,7 +18,7 @@ jobs: name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.18 + go-version: 1.23 - name: Verify dependencies diff --git a/go.mod b/go.mod index 6ef7c9a..192572e 100644 --- a/go.mod +++ b/go.mod @@ -15,64 +15,28 @@ require ( github.com/fatih/color v1.17.0 github.com/peterh/liner v1.2.2 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/spf13/viper v1.21.0 ) require ( - github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/creack/goselect v0.1.2 // indirect - github.com/creack/pty v1.1.9 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/frankban/quicktest v1.14.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gofrs/flock v0.7.1 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jonboulle/clockwork v0.3.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/kr/pty v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/shoenig/test v1.7.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/numcpus v0.8.0 // indirect - github.com/yuin/goldmark v1.4.13 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b // indirect - golang.org/x/term v0.33.0 // indirect golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect - golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/errgo.v2 v2.1.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect periph.io/x/conn/v3 v3.7.0 // indirect - periph.io/x/d2xx v0.1.0 // indirect - periph.io/x/periph v3.6.2+incompatible // indirect ) require ( @@ -84,7 +48,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil/v3 v3.24.4 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect - github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/cobra v1.10.1 github.com/tklauser/go-sysconf v0.3.14 // indirect go.bug.st/serial v1.6.2 golang.org/x/sys v0.34.0 // indirect diff --git a/go.sum b/go.sum index d61cad4..6afb965 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,15 @@ github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= -github.com/blues/note-go v1.5.0/go.mod h1:F66ZqObdOhxRRXIwn9+YhVGqB93jMAnqlO2ibwMa998= github.com/blues/note-go v1.7.4 h1:AqeU6HXkCa7FwDsAao49H6DdTTtNNGJYjGwevZi4Shc= github.com/blues/note-go v1.7.4/go.mod h1:GfslvbmFus7z05P1YykcbMedTKTuDNTf8ryBb1Qjq/4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= @@ -27,16 +26,13 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= @@ -49,13 +45,10 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -64,6 +57,7 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -87,7 +81,6 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -97,13 +90,11 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -117,24 +108,16 @@ github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZF github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.bug.st/serial v1.3.4/go.mod h1:z8CesKorE90Qr/oRSJiEuvzYRKol9r/anJZEb5kt304= go.bug.st/serial v1.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -142,25 +125,15 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -170,4 +143,3 @@ periph.io/x/d2xx v0.1.0/go.mod h1:OflHQcWZ4LDP/2opGYbdXSP/yvWSnHVFO90KRoyobWY= periph.io/x/host/v3 v3.8.0/go.mod h1:rzOLH+2g9bhc6pWZrkCrmytD4igwQ2vxFw6Wn6ZOlLY= periph.io/x/host/v3 v3.8.2 h1:ayKUDzgUCN0g8+/xM9GTkWaOBhSLVcVHGTfjAOi8OsQ= periph.io/x/host/v3 v3.8.2/go.mod h1:yFL76AesNHR68PboofSWYaQTKmvPXsQH2Apvp/ls/K4= -periph.io/x/periph v3.6.2+incompatible/go.mod h1:EWr+FCIU2dBWz5/wSWeiIUJTriYv9v2j2ENBmgYyy7Y= From 923b2114afba36f4f5be5eed216d6ed02f604f69 Mon Sep 17 00:00:00 2001 From: Alex Bucknall Date: Fri, 5 Dec 2025 11:32:22 +0000 Subject: [PATCH 3/4] chore: remove newline --- notehub/cmd/project.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/notehub/cmd/project.go b/notehub/cmd/project.go index 1fc01e4..619420b 100644 --- a/notehub/cmd/project.go +++ b/notehub/cmd/project.go @@ -29,10 +29,10 @@ var projectListCmd = &cobra.Command{ // Get all projects using V1 API: GET /v1/projects type Project struct { - UID string `json:"uid"` - Label string `json:"label"` - BillingAccountUID string `json:"billing_account_uid"` - DisableDevicesByDefault bool `json:"disable_devices_by_default"` + UID string `json:"uid"` + Label string `json:"label"` + BillingAccountUID string `json:"billing_account_uid"` + DisableDevicesByDefault bool `json:"disable_devices_by_default"` } type ProjectsResponse struct { @@ -84,7 +84,7 @@ var projectListCmd = &cobra.Command{ } if currentProject == "" { - fmt.Println("No project selected. Use 'notehub project set ' to select one.\n") + fmt.Println("No project selected. Use 'notehub project set ' to select one.") } // Show credentials user From c68125a4ea13bec573f34ea1ad82bd7bd33ab781 Mon Sep 17 00:00:00 2001 From: Alex Bucknall Date: Mon, 8 Dec 2025 17:07:13 +0000 Subject: [PATCH 4/4] refactor: simplify scoping --- notehub/cmd/device.go | 25 +++++++------------------ notehub/cmd/dfu.go | 16 ++++------------ notehub/cmd/explore.go | 9 +++------ notehub/cmd/helpers.go | 21 +++++++++++++++++++++ notehub/cmd/provision.go | 8 ++------ notehub/cmd/vars.go | 16 ++++------------ 6 files changed, 41 insertions(+), 54 deletions(-) diff --git a/notehub/cmd/device.go b/notehub/cmd/device.go index fc88b4a..b1d4f82 100644 --- a/notehub/cmd/device.go +++ b/notehub/cmd/device.go @@ -168,17 +168,13 @@ Examples: scope := args[0] - verbose := GetVerbose() - appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices to enable") - } - // Enable each device + verbose := GetVerbose() for _, deviceUID := range scopeDevices { url := fmt.Sprintf("/v1/projects/%s/devices/%s/enable", appMetadata.App.UID, deviceUID) err := reqHubV1(verbose, GetAPIHub(), "POST", url, nil, nil) @@ -229,17 +225,13 @@ Examples: scope := args[0] - verbose := GetVerbose() - appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices to disable") - } - // Disable each device + verbose := GetVerbose() for _, deviceUID := range scopeDevices { url := fmt.Sprintf("/v1/projects/%s/devices/%s/disable", appMetadata.App.UID, deviceUID) err := reqHubV1(verbose, GetAPIHub(), "POST", url, nil, nil) @@ -292,16 +284,11 @@ Examples: scope := args[0] targetFleetIdentifier := args[1] - verbose := GetVerbose() - appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices to move") - } - // Find the target fleet by UID or name var targetFleetUID string if strings.HasPrefix(targetFleetIdentifier, "fleet:") { @@ -333,6 +320,8 @@ Examples: } `json:"fleets"` } + verbose := GetVerbose() + // Move each device to the target fleet for _, deviceUID := range scopeDevices { url := fmt.Sprintf("/v1/projects/%s/devices/%s/fleets", appMetadata.App.UID, deviceUID) diff --git a/notehub/cmd/dfu.go b/notehub/cmd/dfu.go index 009dd16..6d26ba8 100644 --- a/notehub/cmd/dfu.go +++ b/notehub/cmd/dfu.go @@ -77,17 +77,13 @@ Examples: return fmt.Errorf("firmware type must be 'host' or 'notecard', got '%s'", firmwareType) } - verbose := GetVerbose() - // Resolve scope to device UIDs - appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices found in scope '%s'", scope) - } + verbose := GetVerbose() // Get additional filter flags tags, _ := cmd.Flags().GetString("tag") @@ -273,17 +269,13 @@ Examples: return fmt.Errorf("firmware type must be 'host' or 'notecard', got '%s'", firmwareType) } - verbose := GetVerbose() - // Resolve scope to device UIDs - appMetadata, scopeDevices, _, err := appGetScope(scope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices found in scope '%s'", scope) - } + verbose := GetVerbose() // Get additional filter flags tags, _ := cmd.Flags().GetString("tag") diff --git a/notehub/cmd/explore.go b/notehub/cmd/explore.go index 2446347..66ae47c 100644 --- a/notehub/cmd/explore.go +++ b/notehub/cmd/explore.go @@ -39,16 +39,13 @@ Example: // If scope is specified, iterate over multiple devices if flagScope != "" { - verbose := GetVerbose() - pretty := GetPretty() - appMetadata, scopeDevices, _, err := appGetScope(flagScope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(flagScope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices found within the specified scope") - } + verbose := GetVerbose() + pretty := GetPretty() for _, deviceUID := range scopeDevices { reqFlagDevice = deviceUID diff --git a/notehub/cmd/helpers.go b/notehub/cmd/helpers.go index e17fdf6..87354ba 100644 --- a/notehub/cmd/helpers.go +++ b/notehub/cmd/helpers.go @@ -169,6 +169,27 @@ func appGetScope(scope string, flagVerbose bool) (appMetadata AppMetadata, scope return } +// ResolveScopeWithValidation is a convenience wrapper around appGetScope that: +// 1. Automatically uses GetVerbose() for the verbose flag +// 2. Validates that at least one device or fleet was found +// 3. Returns a more user-friendly error message +// +// This reduces boilerplate in commands that use scope resolution. +func ResolveScopeWithValidation(scope string) (appMetadata AppMetadata, scopeDevices []string, scopeFleets []string, err error) { + verbose := GetVerbose() + appMetadata, scopeDevices, scopeFleets, err = appGetScope(scope, verbose) + if err != nil { + return + } + + if len(scopeDevices) == 0 && len(scopeFleets) == 0 { + err = fmt.Errorf("no devices or fleets found within the specified scope") + return + } + + return +} + // Recursively add scope func addScope(scope string, appMetadata *AppMetadata, scopeDevices *[]string, scopeFleets *[]string, flagVerbose bool) (err error) { if strings.HasPrefix(scope, "dev:") { diff --git a/notehub/cmd/provision.go b/notehub/cmd/provision.go index bc13feb..c8a86dc 100644 --- a/notehub/cmd/provision.go +++ b/notehub/cmd/provision.go @@ -60,16 +60,12 @@ Examples: return fmt.Errorf("--product must be specified (the product UID to provision devices to)") } - verbose := GetVerbose() - appMetadata, scopeDevices, _, err := appGetScope(flagScope, verbose) + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(flagScope) if err != nil { return err } - if len(scopeDevices) == 0 { - return fmt.Errorf("no devices to provision") - } - + verbose := GetVerbose() err = varsProvisionDevices(appMetadata, scopeDevices, product, flagSn, verbose) if err != nil { return err diff --git a/notehub/cmd/vars.go b/notehub/cmd/vars.go index 402f4fb..a4ea7b3 100644 --- a/notehub/cmd/vars.go +++ b/notehub/cmd/vars.go @@ -46,20 +46,16 @@ Scope can be: return fmt.Errorf("use --scope to specify device(s) or fleet(s)") } - verbose := GetVerbose() - appMetadata, scopeDevices, scopeFleets, err := appGetScope(flagScope, verbose) + appMetadata, scopeDevices, scopeFleets, err := ResolveScopeWithValidation(flagScope) if err != nil { return err } - if len(scopeDevices) == 0 && len(scopeFleets) == 0 { - return fmt.Errorf("no devices or fleets found within the specified scope") - } - if len(scopeDevices) != 0 && len(scopeFleets) != 0 { return fmt.Errorf("scope may include devices or fleets but not both") } + verbose := GetVerbose() var vars map[string]Vars if len(scopeDevices) != 0 { vars, err = varsGetFromDevices(appMetadata, scopeDevices, verbose) @@ -104,16 +100,11 @@ Example: return fmt.Errorf("use --scope to specify device(s) or fleet(s)") } - verbose := GetVerbose() - appMetadata, scopeDevices, scopeFleets, err := appGetScope(flagScope, verbose) + appMetadata, scopeDevices, scopeFleets, err := ResolveScopeWithValidation(flagScope) if err != nil { return err } - if len(scopeDevices) == 0 && len(scopeFleets) == 0 { - return fmt.Errorf("no devices or fleets found within the specified scope") - } - if len(scopeDevices) != 0 && len(scopeFleets) != 0 { return fmt.Errorf("scope may include devices or fleets but not both") } @@ -135,6 +126,7 @@ Example: return err } + verbose := GetVerbose() var vars map[string]Vars if len(scopeDevices) != 0 { vars, err = varsSetFromDevices(appMetadata, scopeDevices, template, verbose)