diff --git a/.gitignore b/.gitignore index a0c4fb7..1cb95d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ notecard-schema/ dist/ +bin/ +notehub/doc/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 93e98d8..78a6824 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -27,6 +27,7 @@ builds: env: - CGO_ENABLED=0 + goos: - linux goarch: @@ -60,6 +61,7 @@ builds: env: - CGO_ENABLED=1 + goos: - windows - darwin diff --git a/go.mod b/go.mod index 3da2634..913c405 100644 --- a/go.mod +++ b/go.mod @@ -7,33 +7,71 @@ replace github.com/blues/note-cli/lib => ./lib require ( github.com/blues/note-cli/lib v0.0.0-20251120160051-d509bdf52531 github.com/blues/note-go v1.9.0 + github.com/blues/notehub-go v0.0.3 + github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.18.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/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/creack/goselect v0.1.3 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.11.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/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - periph.io/x/conn/v3 v3.7.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect + periph.io/x/conn/v3 v3.7.0 // indirect ) require ( github.com/go-ole/go-ole v1.3.0 // indirect - github.com/golang/snappy v1.0.0 - github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/golang/snappy v0.0.4 + github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/shirou/gopsutil/v3 v3.24.5 // indirect + 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/tklauser/go-sysconf v0.3.16 // indirect - go.bug.st/serial v1.6.4 - golang.org/x/sys v0.40.0 // indirect - periph.io/x/host/v3 v3.8.5 // indirect + github.com/spf13/cobra v1.10.2 + github.com/tklauser/go-sysconf v0.3.14 // indirect + go.bug.st/serial v1.6.2 + 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 6c35441..0c92e8c 100644 --- a/go.sum +++ b/go.sum @@ -1,87 +1,214 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/blues/note-go v1.9.0 h1:8otF2IPP4Y9FhMyoL3S2xX4EHVSiy6c+pwdvG9XHJ9k= github.com/blues/note-go v1.9.0/go.mod h1:GfslvbmFus7z05P1YykcbMedTKTuDNTf8ryBb1Qjq/4= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/blues/notehub-go v0.0.3 h1:b+HiSarf68gXRqiascYsqpyF7bGtLDka0A66iQraEuE= +github.com/blues/notehub-go v0.0.3/go.mod h1:a8fgPV3iznI6oIPTTzAGrXnq4vBD3d3jvKEnjAWtC/o= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +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/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= -github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +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= 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 v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/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/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +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/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= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +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= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= +github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +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.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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/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.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= -go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= -go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +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-20210809222454-d867a43fc93e/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= 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.19.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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= 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= +periph.io/x/conn/v3 v3.7.0 h1:f1EXLn4pkf7AEWwkol2gilCNZ0ElY+bxS4WE2PQXfrA= periph.io/x/conn/v3 v3.7.0/go.mod h1:ypY7UVxgDbP9PJGwFSVelRRagxyXYfttVh7hJZUHEhg= -periph.io/x/conn/v3 v3.7.2 h1:qt9dE6XGP5ljbFnCKRJ9OOCoiOyBGlw7JZgoi72zZ1s= -periph.io/x/conn/v3 v3.7.2/go.mod h1:Ao0b4sFRo4QOx6c1tROJU1fLJN1hUIYggjOrkIVnpGg= 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.5 h1:g4g5xE1XZtDiGl1UAJaUur1aT7uNiFLMkyMEiZ7IHII= -periph.io/x/host/v3 v3.8.5/go.mod h1:hPq8dISZIc+UNfWoRj+bPH3XEBQqJPdFdx218W92mdc= +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= diff --git a/notehub/app.go b/notehub/app.go deleted file mode 100644 index 554d95b..0000000 --- a/notehub/app.go +++ /dev/null @@ -1,390 +0,0 @@ -// 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 main - -import ( - "bufio" - "bytes" - "fmt" - "os" - "sort" - "strings" - - "github.com/blues/note-cli/lib" - "github.com/blues/note-go/note" - notegoapi "github.com/blues/note-go/notehub/api" -) - -type Metadata struct { - Name string `json:"name,omitempty"` - UID string `json:"uid,omitempty"` - BA string `json:"billing_account_uid,omitempty"` - Vars map[string]string `json:"vars,omitempty"` -} - -type AppMetadata struct { - App Metadata `json:"app,omitempty"` - Fleets []Metadata `json:"fleets,omitempty"` - Routes []Metadata `json:"routes,omitempty"` - Products []Metadata `json:"products,omitempty"` -} - -// Load metadata for the app -func appGetMetadata(flagVerbose bool, flagVars bool) (appMetadata AppMetadata, err error) { - - rsp := map[string]interface{}{} - err = reqHubV0(flagVerbose, lib.ConfigAPIHub(), []byte("{\"req\":\"hub.app.get\"}"), "", "", "", "", false, false, nil, &rsp) - if err != nil { - return - } - rsperr, _ := rsp["err"].(string) - if rsperr != "" { - err = fmt.Errorf("%s", rsperr) - 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{}) - if exists { - items := []Metadata{} - for k, v := range fleets { - vj, ok := v.(map[string]interface{}) - if ok { - i := Metadata{Name: vj["label"].(string), UID: k} - 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) - if err != nil { - return - } - i.Vars = varsRsp.EnvironmentVariables - } - items = append(items, i) - } - } - appMetadata.Fleets = items - } - } - - // 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) - } - 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 - } - } - - // Products - rsp = map[string]interface{}{} - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", "/v1/projects/"+appMetadata.App.UID+"/products", nil, &rsp) - if err == nil { - pi, exists := rsp["products"].([]interface{}) - if exists { - items := []Metadata{} - for _, v := range pi { - p, ok := v.(map[string]interface{}) - if ok { - i := Metadata{Name: p["label"].(string), UID: p["uid"].(string)} - items = append(items, i) - } - appMetadata.Products = items - } - } - } - - // Done - return - -} - -// Get a device list given -func appGetScope(scope string, flagVerbose bool) (appMetadata AppMetadata, scopeDevices []string, scopeFleets []string, err error) { - - // Process special scopes, which are handled inside addScope - switch scope { - case "devices": - scope = "@" - case "fleets": - scope = "-" - } - - // Get the metadata before we begin, because at a minimum we need appUID - appMetadata, err = appGetMetadata(flagVerbose, false) - if err != nil { - return - } - - // On the command line (but not inside files) we allow comma-separated lists - if strings.Contains(scope, ",") { - scopeList := strings.Split(scope, ",") - for _, scope := range scopeList { - err = addScope(scope, &appMetadata, &scopeDevices, &scopeFleets, flagVerbose) - if err != nil { - return - } - } - } else { - err = addScope(scope, &appMetadata, &scopeDevices, &scopeFleets, flagVerbose) - if err != nil { - return - } - } - - // Remove duplicates - 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 - } - - if strings.HasPrefix(scope, "fleet:") { - *scopeFleets = append(*scopeFleets, scope) - return - } - - // See if this is a fleet name, and translate it to an ID - if !strings.HasPrefix(scope, "@") { - found := false - for _, fleet := range (*appMetadata).Fleets { - if fleetMatchesScope(fleet.Name, scope) { - *scopeFleets = append(*scopeFleets, fleet.UID) - found = true - } - } - if !found { - return fmt.Errorf("'%s' does not appear to be a device, fleet, @fleet indirection, or @file.ext indirection", scope) - } - return - } - - // Process a fleet indirection. First, find the fleet. - indirectScope := strings.TrimPrefix(scope, "@") - foundFleet := false - lookingFor := strings.TrimSpace(indirectScope) - - // Looking for "all devices" or a named fleet - if indirectScope == "" { - // All devices - - pageSize := 500 - pageNum := 0 - for { - pageNum++ - - 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) - if err != nil { - return - } - - for _, device := range devices.Devices { - err = addScope(device.UID, appMetadata, scopeDevices, scopeFleets, flagVerbose) - if err != nil { - return err - } - } - - if !devices.HasMore { - break - } - - } - - return - - } else { - - // Fleet - for _, fleet := range (*appMetadata).Fleets { - if lookingFor == fleet.UID || fleetMatchesScope(fleet.Name, lookingFor) { - foundFleet = true - - pageSize := 100 - pageNum := 0 - for { - pageNum++ - - 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) - if err != nil { - return - } - - for _, device := range devices.Devices { - err = addScope(device.UID, appMetadata, scopeDevices, scopeFleets, flagVerbose) - if err != nil { - return err - } - } - - if !devices.HasMore { - break - } - - } - - } - } - if foundFleet { - return - } - - } - - // Process a file indirection - var contents []byte - contents, err = os.ReadFile(indirectScope) - if err != nil { - return fmt.Errorf("%s: %s", indirectScope, err) - } - - scanner := bufio.NewScanner(bytes.NewReader(contents)) - scanner.Split(bufio.ScanLines) - - for scanner.Scan() { - line := scanner.Text() - if trimmedLine := strings.TrimSpace(line); trimmedLine != "" { - err = addScope(trimmedLine, appMetadata, scopeDevices, scopeFleets, flagVerbose) - if err != nil { - return err - } - } - } - - err = scanner.Err() - return - -} - -// ProjectInfo represents a project with its products -type ProjectInfo struct { - Name string `json:"name,omitempty"` - UID string `json:"uid,omitempty"` - Products []Metadata `json:"products,omitempty"` -} - -// List all projects accessible to the authenticated user -func appListProjects(flagVerbose bool) (projects []ProjectInfo, err error) { - rsp, err := reqHubV1JSON(flagVerbose, lib.ConfigAPIHub(), "GET", "/v1/projects", nil) - if err != nil { - return - } - var parsed map[string]interface{} - err = note.JSONUnmarshal(rsp, &parsed) - if err != nil { - return - } - items, _ := parsed["projects"].([]interface{}) - for _, v := range items { - p, ok := v.(map[string]interface{}) - if !ok { - continue - } - uid, _ := p["uid"].(string) - label, _ := p["label"].(string) - info := ProjectInfo{Name: label, UID: uid} - - // Fetch products for this project - productsRsp := map[string]interface{}{} - err = reqHubV1(flagVerbose, lib.ConfigAPIHub(), "GET", "/v1/projects/"+uid+"/products", nil, &productsRsp) - if err == nil { - pi, exists := productsRsp["products"].([]interface{}) - if exists { - for _, pv := range pi { - pp, ok := pv.(map[string]interface{}) - if ok { - info.Products = append(info.Products, Metadata{ - Name: pp["label"].(string), - UID: pp["uid"].(string), - }) - } - } - } - } - err = nil // don't fail the whole listing if one project's products can't be fetched - - projects = append(projects, info) - } - return -} - -// Sort and remove duplicates in a string slice -func sortAndRemoveDuplicates(strings []string) []string { - - sort.Strings(strings) - - unique := make(map[string]struct{}) - var result []string - - for _, v := range strings { - if _, exists := unique[v]; !exists { - unique[v] = struct{}{} - result = append(result, v) - } - } - - return result -} - -// See if a fleet name matches a scope name -func fleetMatchesScope(fleetName string, scope string) bool { - normalizedScope := strings.ToLower(scope) - scopeWildcard := false - if strings.HasSuffix(normalizedScope, "*") { - normalizedScope = strings.TrimSuffix(normalizedScope, "*") - scopeWildcard = true - } - normalizedName := strings.ToLower(fleetName) - match := scope == "-" || normalizedName == normalizedScope - if scopeWildcard { - if strings.HasPrefix(normalizedName, normalizedScope) { - match = true - } - } - return match -} 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..bb4cc2b --- /dev/null +++ b/notehub/cmd/auth.go @@ -0,0 +1,305 @@ +// Copyright 2025 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/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 { + cmd.Printf("%s\n", banner()) + cmd.Printf("signed in as %s\n", accessToken.Email) + cmd.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(cmd, 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 + cmd.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 + cmd.Printf("signed in successfully with token\n") + + // Set project if provided via flag or prompt for selection + if err := handleProjectSelection(cmd, 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", "") + viper.Set("project_label", "") + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + cmd.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'") + } + + cmd.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(cmd *cobra.Command, 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(cmd, projectFlag) + } + + // Otherwise, offer interactive selection + return interactiveProjectSelection(cmd) +} + +// setProjectByIdentifier sets a project by name or UID (from project.go logic) +func setProjectByIdentifier(cmd *cobra.Command, identifier string) error { + // Get SDK client + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + // First, try to use it directly as a UID + project, resp, err := client.ProjectAPI.GetProject(ctx, identifier).Execute() + + // If that failed, it might be a project name - fetch all projects and search + if err != nil || (resp != nil && resp.StatusCode == 404) { + projectsRsp, _, err := client.ProjectAPI.GetProjects(ctx).Execute() + if err != nil { + return fmt.Errorf("failed to list projects: %w", err) + } + + // Search for project by name (exact match) + found := false + for _, proj := range projectsRsp.Projects { + if proj.Label == identifier { + project = &proj + found = true + break + } + } + + if !found { + return fmt.Errorf("project '%s' not found", identifier) + } + } + + // Save to config + viper.Set("project", project.Uid) + viper.Set("project_label", project.Label) + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + cmd.Printf("\nActive project set to: %s\n", project.Label) + cmd.Printf("Project UID: %s\n\n", project.Uid) + + return nil +} + +// interactiveProjectSelection prompts the user to select a project interactively +func interactiveProjectSelection(cmd *cobra.Command) error { + // Get SDK client + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + // Fetch all projects + projectsRsp, _, err := client.ProjectAPI.GetProjects(ctx).Execute() + if err != nil { + cmd.Println() + cmd.Println("To get started, you'll need to select a project to work with.") + cmd.Println("Run 'notehub project list' to see your available projects,") + cmd.Println("then 'notehub project set ' to select one.") + cmd.Println() + return nil + } + + if len(projectsRsp.Projects) == 0 { + cmd.Println() + cmd.Println("No projects found. You can create a new project at https://notehub.io") + cmd.Println() + return nil + } + + // Build picker items + items := make([]PickerItem, len(projectsRsp.Projects)) + for i, project := range projectsRsp.Projects { + items[i] = PickerItem{Label: project.Label, Value: project.Uid} + } + + selectedUID, err := pickPaginated("Select a project", "no projects found", func(page int32) (PickerPage, error) { + return PickerPage{Items: items, HasMore: false}, nil + }) + if err != nil { + cmd.Println() + cmd.Println("Skipped project selection. You can set a project later with 'notehub project set '") + cmd.Println() + return nil + } + + // Find the label for the selected project + var selectedLabel string + for _, item := range items { + if item.Value == selectedUID { + selectedLabel = item.Label + break + } + } + + // Set the selected project + viper.Set("project", selectedUID) + viper.Set("project_label", selectedLabel) + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + cmd.Printf("Active project set to: %s\n", selectedLabel) + cmd.Printf("Project UID: %s\n", selectedUID) + + return nil +} diff --git a/notehub/cmd/billing.go b/notehub/cmd/billing.go new file mode 100644 index 0000000..1714224 --- /dev/null +++ b/notehub/cmd/billing.go @@ -0,0 +1,46 @@ +// Copyright 2025 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" +) + +// billingCmd represents the billing command +var billingCmd = &cobra.Command{ + Use: "billing", + Short: "Manage billing accounts", + Long: `Commands for listing billing accounts.`, +} + +// billingListCmd represents the billing list command +var billingListCmd = &cobra.Command{ + Use: "list", + Short: "List billing accounts", + Long: `List all billing accounts accessible by the current authentication.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + billingRsp, _, err := client.BillingAccountAPI.GetBillingAccounts(ctx).Execute() + if err != nil { + return fmt.Errorf("failed to list billing accounts: %w", err) + } + + return printListResult(cmd, billingRsp, "No billing accounts found.", func() bool { + return len(billingRsp.BillingAccounts) == 0 + }) + }, +} + +func init() { + rootCmd.AddCommand(billingCmd) + billingCmd.AddCommand(billingListCmd) +} diff --git a/notehub/cmd/config.go b/notehub/cmd/config.go new file mode 100644 index 0000000..8cb2890 --- /dev/null +++ b/notehub/cmd/config.go @@ -0,0 +1,325 @@ +// Copyright 2025 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 { + if isNetworkError(err) { + return "", fmt.Errorf("unable to reach %s: %w", hub, err) + } + 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 { + return fmt.Errorf("please sign in using 'notehub auth signin' or 'notehub auth signin-token'") + } + + // Set the header + httpReq.Header.Set("Authorization", "Bearer "+credentials.Token) + + return nil +} + +// configOutput is the structured representation of the CLI configuration. +type configOutput struct { + Hub string `json:"hub"` + Credentials *configCredentials `json:"credentials,omitempty"` + Settings map[string]configSetting `json:"settings,omitempty"` + ConfigFile string `json:"config_file"` +} + +type configSetting struct { + UID string `json:"uid"` + Label string `json:"label,omitempty"` +} + +type configCredentials struct { + User string `json:"user"` + TokenType string `json:"token_type"` + ExpiresAt string `json:"expires_at,omitempty"` + Expired bool `json:"expired,omitempty"` +} + +func buildConfigOutput() configOutput { + hub := GetHub() + + output := configOutput{ + Hub: hub, + ConfigFile: viper.ConfigFileUsed(), + } + if output.ConfigFile == "" { + output.ConfigFile = getConfigPath() + } + + // Credentials + credentials, _ := GetHubCredentials() + if credentials != nil && credentials.User != "" { + creds := &configCredentials{ + User: credentials.User, + } + if credentials.IsOAuthAccessToken() { + creds.TokenType = "OAuth" + } else { + creds.TokenType = "Personal Access Token" + } + if credentials.ExpiresAt != nil { + creds.ExpiresAt = credentials.ExpiresAt.Format(time.RFC3339) + creds.Expired = credentials.ExpiresAt.Before(time.Now()) + } + output.Credentials = creds + } + + // Active settings (only non-empty, include labels when available) + settingKeys := []string{"project", "fleet", "product", "route", "monitor", "device"} + settings := make(map[string]configSetting) + for _, key := range settingKeys { + if val := viper.GetString(key); val != "" { + s := configSetting{UID: val} + if label := viper.GetString(key + "_label"); label != "" && label != val { + s.Label = label + } + settings[key] = s + } + } + if len(settings) > 0 { + output.Settings = settings + } + + return output +} + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Display current configuration", + Long: `Display the current configuration including hub, credentials, and active settings.`, + RunE: func(cmd *cobra.Command, args []string) error { + return printResult(cmd, buildConfigOutput()) + }, +} + +func init() { + rootCmd.AddCommand(configCmd) +} diff --git a/notehub/cmd/device.go b/notehub/cmd/device.go new file mode 100644 index 0000000..db76dc6 --- /dev/null +++ b/notehub/cmd/device.go @@ -0,0 +1,760 @@ +// Copyright 2025 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" + + notehub "github.com/blues/notehub-go" + "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 devices in the current project or a specified project. + +By default, returns up to 50 devices. Use --limit to change the number, or --all to fetch every device. + +Examples: + # List first 50 devices (default) + notehub device list + + # List first 100 devices + notehub device list --limit 100 + + # List all devices + notehub device list --all`, + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + pageSize, maxResults := getPaginationConfig(cmd) + + var allDevices []notehub.Device + pageNum := int32(1) + for { + devicesResp, _, err := client.DeviceAPI.GetDevices(ctx, projectUID). + PageSize(pageSize). + PageNum(pageNum). + Execute() + if err != nil { + return fmt.Errorf("failed to list devices: %w", err) + } + + allDevices = append(allDevices, devicesResp.Devices...) + + if !devicesResp.HasMore { + break + } + if maxResults > 0 && len(allDevices) >= maxResults { + allDevices = allDevices[:maxResults] + break + } + pageNum++ + } + + return printListResult(cmd, allDevices, "No devices found in this project.", func() bool { + return len(allDevices) == 0 + }) + }, +} + +// deviceEnableDisable is the shared implementation for enable/disable commands. +func deviceEnableDisable(cmd *cobra.Command, scope string, enable bool) error { + action := "enable" + pastTense := "Enabled" + if !enable { + action = "disable" + pastTense = "Disabled" + } + + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) + if err != nil { + return err + } + + if !enable { + if err := confirmAction(cmd, fmt.Sprintf("Disable %d device(s)?", len(scopeDevices))); err != nil { + return nil + } + } + + verbose := GetVerbose() + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + for _, deviceUID := range scopeDevices { + if enable { + _, err = client.DeviceAPI.EnableDevice(ctx, appMetadata.App.UID, deviceUID).Execute() + } else { + _, err = client.DeviceAPI.DisableDevice(ctx, appMetadata.App.UID, deviceUID).Execute() + } + if err != nil { + return fmt.Errorf("failed to %s device %s: %w", action, deviceUID, err) + } + if verbose { + cmd.Printf("Device %s %s\n", deviceUID, strings.ToLower(pastTense)) + } + } + + return printActionResult(cmd, map[string]any{ + "action": action, + "devices": scopeDevices, + "count": len(scopeDevices), + }, fmt.Sprintf("%s %d device(s)", pastTense, len(scopeDevices))) +} + +// 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.` + scopeHelpLong + ` + +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 { + return deviceEnableDisable(cmd, args[0], true) + }, +} + +// 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.` + scopeHelpLong + ` + +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 { + return deviceEnableDisable(cmd, args[0], false) + }, +} + +// 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.` + scopeHelpLong + ` + +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 { + scope := args[0] + targetFleetIdentifier := args[1] + + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) + if err != nil { + return err + } + + verbose := GetVerbose() + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + // Resolve target fleet by UID or name + targetFleet, err := resolveFleet(client, ctx, appMetadata.App.UID, targetFleetIdentifier) + if err != nil { + return err + } + targetFleetUID := targetFleet.Uid + + // Move each device to the target fleet using SDK + for _, deviceUID := range scopeDevices { + // First, get the device's current fleets + currentFleets, _, err := client.ProjectAPI.GetDeviceFleets(ctx, appMetadata.App.UID, deviceUID).Execute() + 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 currentFleets.Fleets != nil && len(currentFleets.Fleets) > 0 { + currentFleetUIDs := make([]string, len(currentFleets.Fleets)) + for i, fleet := range currentFleets.Fleets { + currentFleetUIDs[i] = fleet.Uid + } + + deleteReq := notehub.NewDeleteDeviceFromFleetsRequest(currentFleetUIDs) + _, _, err = client.ProjectAPI.DeleteDeviceFromFleets(ctx, appMetadata.App.UID, deviceUID). + DeleteDeviceFromFleetsRequest(*deleteReq). + Execute() + if err != nil { + return fmt.Errorf("failed to remove device %s from current fleets: %w", deviceUID, err) + } + if verbose { + cmd.Printf("Device %s removed from %d fleet(s)\n", deviceUID, len(currentFleetUIDs)) + } + } + + // Add device to the target fleet + addReq := notehub.NewAddDeviceToFleetsRequest([]string{targetFleetUID}) + _, _, err = client.ProjectAPI.AddDeviceToFleets(ctx, appMetadata.App.UID, deviceUID). + AddDeviceToFleetsRequest(*addReq). + Execute() + if err != nil { + return fmt.Errorf("failed to move device %s to fleet: %w", deviceUID, err) + } + if verbose { + cmd.Printf("Device %s moved to fleet %s\n", deviceUID, targetFleetUID) + } + } + + return printActionResult(cmd, map[string]any{ + "action": "move", + "devices": scopeDevices, + "count": len(scopeDevices), + "fleet_uid": targetFleetUID, + }, fmt.Sprintf("Moved %d device(s) to fleet %s", len(scopeDevices), targetFleetUID)) + }, +} + +// 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.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + healthLogRsp, _, err := client.DeviceAPI.GetDeviceHealthLog(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device health log: %w", err) + } + + // Handle JSON output + return printListResult(cmd, healthLogRsp, "No health log entries found for this device.", func() bool { + return len(healthLogRsp.HealthLog) == 0 + }) + }, +} + +// 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 more sessions + notehub device session dev:864475046552567 --limit 100 + + # Get all sessions + notehub device session dev:864475046552567 --all`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + pageSize, maxResults := getPaginationConfig(cmd) + + var allSessions []notehub.DeviceSession + pageNum := int32(1) + for { + sessionsRsp, _, err := client.DeviceAPI.GetDeviceSessions(ctx, projectUID, deviceUID). + PageSize(pageSize). + PageNum(pageNum). + Execute() + if err != nil { + return fmt.Errorf("failed to get device sessions: %w", err) + } + + allSessions = append(allSessions, sessionsRsp.Sessions...) + + if !sessionsRsp.HasMore { + break + } + if maxResults > 0 && len(allSessions) >= maxResults { + allSessions = allSessions[:maxResults] + break + } + pageNum++ + } + + // Handle JSON output + return printListResult(cmd, allSessions, "No sessions found for this device.", func() bool { + return len(allSessions) == 0 + }) + }, +} + +// deviceGetCmd represents the device get command +var deviceGetCmd = &cobra.Command{ + Use: "get [device-uid]", + Short: "Get device details", + Long: `Get details about a specific device in the current project. + +Examples: + # Get device details + notehub device get dev:864475046552567 + + # Get device details with JSON output + notehub device get dev:864475046552567 --json + + # Get device details with pretty JSON + notehub device get dev:864475046552567 --pretty`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + device, _, err := client.DeviceAPI.GetDevice(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + return printResult(cmd, device) + }, +} + +// deviceDeleteCmd represents the device delete command +var deviceDeleteCmd = &cobra.Command{ + Use: "delete [device-uid]", + Short: "Delete a device", + Long: `Delete a device from the current project. + +Examples: + # Delete a device + notehub device delete dev:864475046552567`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + if err := confirmAction(cmd, fmt.Sprintf("Delete device '%s'?", deviceUID)); err != nil { + return nil + } + + _, err = client.DeviceAPI.DeleteDevice(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to delete device: %w", err) + } + + return printActionResult(cmd, map[string]any{ + "action": "delete", + "device_uid": deviceUID, + }, fmt.Sprintf("Device '%s' deleted", deviceUID)) + }, +} + +// deviceSignalCmd represents the device signal command +var deviceSignalCmd = &cobra.Command{ + Use: "signal [device-uid]", + Short: "Send a signal to a device", + Long: `Send a signal to a device to check if it is currently connected. + +Examples: + # Signal a device + notehub device signal dev:864475046552567 + + # Signal a device with JSON output + notehub device signal dev:864475046552567 --json + + # Signal a device with pretty JSON + notehub device signal dev:864475046552567 --pretty`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + signalResp, _, err := client.DeviceAPI.SignalDevice(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to signal device: %w", err) + } + + return printResult(cmd, signalResp) + }, +} + +// deviceEventsCmd represents the device events command +var deviceEventsCmd = &cobra.Command{ + Use: "events [device-uid]", + Short: "Get latest events for a device", + Long: `Get the latest events for a specific device. + +Examples: + # Get latest events for a device + notehub device events dev:864475046552567 + + # Get latest events with JSON output + notehub device events dev:864475046552567 --json + + # Get latest events with pretty JSON + notehub device events dev:864475046552567 --pretty`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + eventsRsp, _, err := client.DeviceAPI.GetDeviceLatestEvents(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device latest events: %w", err) + } + + // Handle JSON output + return printListResult(cmd, eventsRsp, "No events found for this device.", func() bool { + return len(eventsRsp.LatestEvents) == 0 + }) + }, +} + +// devicePlansCmd represents the device plans command +var devicePlansCmd = &cobra.Command{ + Use: "plans [device-uid]", + Short: "Get data plans for a device", + Long: `Get the data plans associated with a device, including primary SIM, external SIM, and satellite connections. + +Examples: + # Get data plans for a device + notehub device plans dev:864475046552567 + + # Get data plans with JSON output + notehub device plans dev:864475046552567 --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + plansRsp, _, err := client.DeviceAPI.GetDevicePlans(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device plans: %w", err) + } + + // Handle JSON output + return printListResult(cmd, plansRsp, "No data plans found for this device.", func() bool { + return len(plansRsp.CellularPlans) == 0 + }) + }, +} + +// deviceKeysCmd represents the device keys command +var deviceKeysCmd = &cobra.Command{ + Use: "keys [device-uid]", + Short: "Get public key for a device", + Long: `Get the public key for a specific device, or list all device public keys in the project. + +Examples: + # Get public key for a specific device + notehub device keys dev:864475046552567 + + # List all device public keys in the project + notehub device keys --all + + # List all with JSON output + notehub device keys --all --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + listAll, _ := cmd.Flags().GetBool("all") + + if listAll { + limit, _ := cmd.Flags().GetInt("limit") + pageSize := int32(limit) + maxResults := limit + + var allKeys []notehub.GetDevicePublicKeys200ResponseDevicePublicKeysInner + pageNum := int32(1) + for { + keysRsp, _, err := client.DeviceAPI.GetDevicePublicKeys(ctx, projectUID). + PageSize(pageSize). + PageNum(pageNum). + Execute() + if err != nil { + return fmt.Errorf("failed to list device public keys: %w", err) + } + + allKeys = append(allKeys, keysRsp.DevicePublicKeys...) + + if !keysRsp.HasMore { + break + } + if maxResults > 0 && len(allKeys) >= maxResults { + allKeys = allKeys[:maxResults] + break + } + pageNum++ + } + + return printListResult(cmd, allKeys, "No device public keys found.", func() bool { + return len(allKeys) == 0 + }) + } + + // Single device public key + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + keyRsp, _, err := client.DeviceAPI.GetDevicePublicKey(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device public key: %w", err) + } + + return printResult(cmd, keyRsp) + }, +} + +// deviceConfigCmd represents the device config command +var deviceConfigCmd = &cobra.Command{ + Use: "config [device-uid]", + Short: "Get device environment variable configuration", + Long: `Show the full environment variable hierarchy for a device, including +variables inherited from the project and fleet levels. + +This shows the effective configuration that the Notecard will receive, +including which level (project, fleet, device) each variable comes from. + +Examples: + # Get config for a device + notehub device config dev:864475046552567 + + # Get config with JSON output + notehub device config dev:864475046552567 --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + deviceUID, err := resolveDeviceArg(client, ctx, projectUID, args) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + + hierarchy, _, err := client.DeviceAPI.GetDeviceEnvironmentHierarchy(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device config: %w", err) + } + + return printResult(cmd, hierarchy) + }, +} + +// deviceSetCmd represents the device set command +var deviceSetCmd = &cobra.Command{ + Use: "set [device-uid]", + Short: "Set the active device", + Long: `Set the active device in the configuration. This device will be used as the +default for commands like get, health, session, events, etc. + +If no argument is provided, an interactive picker will be shown.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + var deviceUID string + if len(args) > 0 { + deviceUID = args[0] + } else { + deviceUID, err = pickDevice(client, ctx, projectUID) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + } + + // Verify the device exists + device, _, err := client.DeviceAPI.GetDevice(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + label := device.Uid + if device.SerialNumber != nil && *device.SerialNumber != "" { + label = *device.SerialNumber + } + + return setDefault(cmd, "device", device.Uid, label) + }, +} + +// deviceClearCmd represents the device clear command +var deviceClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear the active device", + Long: `Clear the active device from the configuration.`, + RunE: func(cmd *cobra.Command, args []string) error { + return clearDefault(cmd, "device", "notehub device set ") + }, +} + +func init() { + rootCmd.AddCommand(deviceCmd) + deviceCmd.AddCommand(deviceListCmd) + deviceCmd.AddCommand(deviceGetCmd) + deviceCmd.AddCommand(deviceDeleteCmd) + deviceCmd.AddCommand(deviceSignalCmd) + deviceCmd.AddCommand(deviceEnableCmd) + deviceCmd.AddCommand(deviceDisableCmd) + deviceCmd.AddCommand(deviceMoveCmd) + deviceCmd.AddCommand(deviceHealthCmd) + deviceCmd.AddCommand(deviceSessionCmd) + deviceCmd.AddCommand(deviceEventsCmd) + deviceCmd.AddCommand(devicePlansCmd) + deviceCmd.AddCommand(deviceKeysCmd) + deviceCmd.AddCommand(deviceConfigCmd) + deviceCmd.AddCommand(deviceSetCmd) + deviceCmd.AddCommand(deviceClearCmd) + + deviceKeysCmd.Flags().Bool("all", false, "List public keys for all devices in the project") + deviceKeysCmd.Flags().Int("limit", 50, "Maximum number of keys to return (used with --all)") + + addConfirmFlag(deviceDeleteCmd) + addConfirmFlag(deviceDisableCmd) + + addPaginationFlags(deviceListCmd, 50) + addPaginationFlags(deviceSessionCmd, 50) +} diff --git a/notehub/cmd/dfu.go b/notehub/cmd/dfu.go new file mode 100644 index 0000000..3c0d742 --- /dev/null +++ b/notehub/cmd/dfu.go @@ -0,0 +1,290 @@ +// Copyright 2025 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" + + notehub "github.com/blues/notehub-go" + "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.`, +} + +// dfuAction is the shared implementation for DFU update and cancel commands. +func dfuAction(cmd *cobra.Command, firmwareType, action, scope, filename string) error { + // Validate firmware type + if firmwareType != "host" && firmwareType != "notecard" { + return fmt.Errorf("firmware type must be 'host' or 'notecard', got '%s'", firmwareType) + } + + // Resolve scope to device UIDs + appMetadata, scopeDevices, _, err := ResolveScopeWithValidation(scope) + if err != nil { + return err + } + + // Get filter flags (shared by both update and cancel) + tags, _ := cmd.Flags().GetString("tag") + serialNumbers, _ := cmd.Flags().GetString("serial") + + // Get SDK client + client := GetNotehubClient() + ctx, err := GetNotehubContext() + if err != nil { + return err + } + + // Build request with SDK + req := client.ProjectAPI.PerformDfuAction(ctx, appMetadata.App.UID, firmwareType, action) + + // Set filename for update action + if filename != "" { + dfuRequest := notehub.NewDfuActionRequest() + dfuRequest.SetFilename(filename) + req = req.DfuActionRequest(*dfuRequest) + } + + // Add device UIDs + if len(scopeDevices) > 0 { + req = req.DeviceUID(scopeDevices) + } + + // Add shared filters + if tags != "" { + req = req.Tag(strings.Split(tags, ",")) + } + if serialNumbers != "" { + req = req.SerialNumber(strings.Split(serialNumbers, ",")) + } + + // Add update-only filters + if action == "update" { + if location, _ := cmd.Flags().GetString("location"); location != "" { + req = req.Location([]string{location}) + } + if notecardFirmware, _ := cmd.Flags().GetString("notecard-firmware"); notecardFirmware != "" { + req = req.NotecardFirmware([]string{notecardFirmware}) + } + if hostFirmware, _ := cmd.Flags().GetString("host-firmware"); hostFirmware != "" { + req = req.HostFirmware([]string{hostFirmware}) + } + if productUID, _ := cmd.Flags().GetString("product"); productUID != "" { + req = req.ProductUID([]string{productUID}) + } + if sku, _ := cmd.Flags().GetString("sku"); sku != "" { + req = req.Sku([]string{sku}) + } + } + + // Execute the DFU action + _, err = req.Execute() + if err != nil { + return fmt.Errorf("failed to %s firmware update: %w", action, err) + } + + // Build result + result := map[string]any{ + "action": action, + "firmware_type": firmwareType, + "scope": scope, + "devices": scopeDevices, + "device_count": len(scopeDevices), + } + if filename != "" { + result["filename"] = filename + } + if tags != "" { + result["tag_filter"] = tags + } + if serialNumbers != "" { + result["serial_filter"] = serialNumbers + } + + var successMsg string + if action == "update" { + successMsg = fmt.Sprintf("Firmware update scheduled for %d device(s)\nFirmware Type: %s\nFilename: %s\nScope: %s", len(scopeDevices), firmwareType, filename, scope) + } else { + successMsg = fmt.Sprintf("Firmware update cancelled for %d device(s)\nFirmware Type: %s\nScope: %s", len(scopeDevices), firmwareType, scope) + } + + return printActionResult(cmd, result, successMsg) +} + +// 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.` + scopeHelpLong + ` + +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 { + return dfuAction(cmd, args[0], "update", args[2], args[1]) + }, +} + +// 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'.` + scopeHelpLong + ` + +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 { + return dfuAction(cmd, args[0], "cancel", args[1], "") + }, +} + +// 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 { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + // 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") + + // Build request with SDK + req := client.ProjectAPI.GetFirmwareInfo(ctx, projectUID) + + // Add query parameters + if firmwareType != "" { + req = req.FirmwareType(firmwareType) + } + if productUID != "" { + req = req.Product(productUID) + } + if version != "" { + req = req.Version(version) + } + if target != "" { + req = req.Target(target) + } + if filename != "" { + req = req.Filename(filename) + } + if unpublished { + req = req.Unpublished(unpublished) + } + + // Get firmware list using SDK + firmwareList, _, err := req.Execute() + if err != nil { + return fmt.Errorf("failed to list firmware: %w", err) + } + + return printListResult(cmd, firmwareList, "No firmware files found.", func() bool { + return len(firmwareList) == 0 + }) + }, +} + +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/docs.go b/notehub/cmd/docs.go new file mode 100644 index 0000000..b4e79f5 --- /dev/null +++ b/notehub/cmd/docs.go @@ -0,0 +1,52 @@ +// Copyright 2025 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/cobra/doc" +) + +// docsCmd represents the docs command +var docsCmd = &cobra.Command{ + Use: "docs", + Short: "Generate CLI documentation", + Long: `Generate Markdown documentation for all CLI commands.`, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + outputDir, _ := cmd.Flags().GetString("output") + + // Resolve to absolute path + absDir, err := filepath.Abs(outputDir) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Create output directory + if err := os.MkdirAll(absDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Disable the auto-generated "Auto generated by spf13/cobra" date line + rootCmd.DisableAutoGenTag = true + + if err := doc.GenMarkdownTree(rootCmd, absDir); err != nil { + return fmt.Errorf("failed to generate docs: %w", err) + } + + cmd.Printf("Documentation generated in %s\n", absDir) + return nil + }, +} + +func init() { + rootCmd.AddCommand(docsCmd) + + docsCmd.Flags().StringP("output", "o", "./doc/cli", "Output directory for generated documentation") +} diff --git a/notehub/cmd/event.go b/notehub/cmd/event.go new file mode 100644 index 0000000..13e39ef --- /dev/null +++ b/notehub/cmd/event.go @@ -0,0 +1,120 @@ +// Copyright 2025 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" + + notehub "github.com/blues/notehub-go" + "github.com/spf13/cobra" +) + +// eventCmd represents the event command +var eventCmd = &cobra.Command{ + Use: "event", + Short: "Manage Notehub events", + Long: `Commands for listing and managing events in Notehub projects.`, +} + +// eventListCmd represents the event list command +var eventListCmd = &cobra.Command{ + Use: "list", + Short: "List events", + Long: `List events in the current project, with optional filtering by device, fleet, or notefile. + +By default, returns up to 50 events. Use --limit to change the number, or --all to fetch every event. + +Examples: + # List first 50 events (default) + notehub event list + + # List first 100 events + notehub event list --limit 100 + + # List all events + notehub event list --all + + # Filter by device + notehub event list --device dev:864475046552567 + + # Continue from a cursor + notehub event list --cursor `, + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + // Get flags + limit, _ := cmd.Flags().GetInt32("limit") + fetchAll, _ := cmd.Flags().GetBool("all") + cursor, _ := cmd.Flags().GetString("cursor") + deviceFilter, _ := cmd.Flags().GetString("device") + fleetFilter, _ := cmd.Flags().GetString("fleet") + fileFilter, _ := cmd.Flags().GetString("file") + sortOrder, _ := cmd.Flags().GetString("sort-order") + + maxResults := int(limit) + pageSize := limit + if fetchAll { + pageSize = 500 + maxResults = 0 + } + + var allEvents []notehub.Event + for { + req := client.EventAPI.GetEventsByCursor(ctx, projectUID) + req = req.Limit(pageSize) + if cursor != "" { + req = req.Cursor(cursor) + } + if deviceFilter != "" { + req = req.DeviceUID([]string{deviceFilter}) + } + if fleetFilter != "" { + req = req.FleetUID(fleetFilter) + } + if fileFilter != "" { + req = req.Files(fileFilter) + } + if sortOrder != "" { + req = req.SortOrder(sortOrder) + } + + eventsRsp, _, err := req.Execute() + if err != nil { + return fmt.Errorf("failed to list events: %w", err) + } + + allEvents = append(allEvents, eventsRsp.Events...) + + if !eventsRsp.HasMore || eventsRsp.NextCursor == "" { + break + } + if maxResults > 0 && len(allEvents) >= maxResults { + allEvents = allEvents[:maxResults] + break + } + cursor = eventsRsp.NextCursor + } + + return printListResult(cmd, allEvents, "No events found.", func() bool { + return len(allEvents) == 0 + }) + }, +} + +func init() { + rootCmd.AddCommand(eventCmd) + eventCmd.AddCommand(eventListCmd) + + eventListCmd.Flags().Int32("limit", 50, "Maximum number of events to return") + eventListCmd.Flags().Bool("all", false, "Fetch all events (may be slow for large datasets)") + eventListCmd.Flags().String("cursor", "", "Cursor for pagination") + eventListCmd.Flags().String("device", "", "Filter by device UID") + eventListCmd.Flags().String("fleet", "", "Filter by fleet UID") + eventListCmd.Flags().String("file", "", "Filter by notefile name") + eventListCmd.Flags().String("sort-order", "desc", "Sort order: asc or desc") +} diff --git a/notehub/cmd/explore.go b/notehub/cmd/explore.go new file mode 100644 index 0000000..241856e --- /dev/null +++ b/notehub/cmd/explore.go @@ -0,0 +1,179 @@ +// Copyright 2025 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 ( + "context" + "fmt" + "sort" + "strings" + + "github.com/blues/note-go/note" + notehub "github.com/blues/notehub-go" + "github.com/spf13/cobra" +) + +var ( + flagReserved bool +) + +// exploreCmd represents the explore command +var exploreCmd = &cobra.Command{ + Use: "explore [device-uid]", + Short: "Explore the contents of a device", + Long: `Explore the notefiles and notes on a device. + +By default, reserved notefiles (starting with '_') are not shown. +Use --reserved to include them. + +Examples: + # Explore a device + notehub explore dev:864475046552567 + + # Explore with pretty output + notehub explore dev:864475046552567 --pretty + + # Include reserved notefiles + notehub explore dev:864475046552567 --reserved + + # Explore multiple devices via scope + notehub explore --scope @production --reserved`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + pretty := GetPretty() + + // Determine device(s) to explore + if flagScope != "" { + _, scopeDevices, _, err := ResolveScopeWithValidation(flagScope) + if err != nil { + return err + } + for _, deviceUID := range scopeDevices { + if err := exploreDevice(cmd, client, ctx, projectUID, deviceUID, flagReserved, pretty); err != nil { + return err + } + } + return nil + } + + // Single device + var deviceUID string + if len(args) > 0 { + deviceUID = args[0] + } else { + deviceUID = GetDevice() + } + if deviceUID == "" { + return fmt.Errorf("device UID argument or --scope is required") + } + + return exploreDevice(cmd, client, ctx, projectUID, deviceUID, flagReserved, pretty) + }, +} + +func init() { + rootCmd.AddCommand(exploreCmd) + + exploreCmd.Flags().BoolVarP(&flagReserved, "reserved", "r", false, "Include reserved notefiles") + addScopeFlag(exploreCmd, "Device scope (alternative to positional arg)") +} + +// exploreDevice lists all notefiles on a device and displays their notes. +func exploreDevice(cmd *cobra.Command, client *notehub.APIClient, ctx context.Context, projectUID, deviceUID string, includeReserved, pretty bool) error { + // List notefiles on the device + notefiles, _, err := client.DeviceAPI.ListNotefiles(ctx, projectUID, deviceUID).Execute() + if err != nil { + return fmt.Errorf("failed to list Notefiles on %s: %w", deviceUID, err) + } + + cmd.Printf("%s\n", deviceUID) + + if len(notefiles) == 0 { + cmd.Printf(" no notefiles\n") + return nil + } + + // Collect and sort notefile IDs, filtering reserved if needed + var notefileIDs []string + for _, nf := range notefiles { + id := nf.GetId() + if !includeReserved && strings.HasPrefix(id, "_") { + continue + } + notefileIDs = append(notefileIDs, id) + } + sort.Strings(notefileIDs) + + if len(notefileIDs) == 0 { + cmd.Printf(" no notefiles\n") + return nil + } + + // Get and display notes for each notefile + for _, notefileID := range notefileIDs { + cmd.Printf(" %s\n", notefileID) + + resp, _, err := client.DeviceAPI.GetNotefile(ctx, projectUID, deviceUID, notefileID). + Deleted(true). + Execute() + if err != nil { + cmd.Printf(" (error: %s)\n", err) + continue + } + + notes := resp.GetNotes() + if len(notes) == 0 { + continue + } + + // Sort note IDs for consistent output + noteIDs := make([]string, 0, len(notes)) + for noteID := range notes { + noteIDs = append(noteIDs, noteID) + } + sort.Strings(noteIDs) + + for _, noteID := range noteIDs { + noteData := notes[noteID] + cmd.Printf(" %s", noteID) + + // Try to extract deleted flag from the note data + if noteMap, ok := noteData.(map[string]interface{}); ok { + if deleted, ok := noteMap["deleted"].(bool); ok && deleted { + cmd.Printf(" (DELETED)") + } + cmd.Printf("\n") + + // Display body + if body, ok := noteMap["body"]; ok && body != nil { + prefix := " " + var bodyJSON []byte + if pretty { + bodyJSON, _ = note.JSONMarshalIndent(body, prefix, " ") + } else { + bodyJSON, _ = note.JSONMarshal(body) + } + if len(bodyJSON) > 0 { + cmd.Printf("%s%s\n", prefix, string(bodyJSON)) + } + } + + // Display payload info + if payload, ok := noteMap["payload"].(string); ok && payload != "" { + cmd.Printf(" Payload: %d bytes\n", len(payload)) + } + } else { + cmd.Printf("\n") + } + } + } + + return nil +} diff --git a/notehub/cmd/fleet.go b/notehub/cmd/fleet.go new file mode 100644 index 0000000..59e0087 --- /dev/null +++ b/notehub/cmd/fleet.go @@ -0,0 +1,315 @@ +// Copyright 2025 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" + + notehub "github.com/blues/notehub-go" + "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 { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + fleetsRsp, _, err := client.ProjectAPI.GetFleets(ctx, projectUID).Execute() + if err != nil { + return fmt.Errorf("failed to list fleets: %w", err) + } + + return printListResult(cmd, fleetsRsp, "No fleets found in this project.", func() bool { + return len(fleetsRsp.Fleets) == 0 + }) + }, +} + +// 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. If no argument is provided, uses the active fleet (set with 'fleet set'). If no active fleet is configured, an interactive picker will be shown.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + var fleetIdentifier string + if len(args) > 0 { + fleetIdentifier = args[0] + } else if def := GetFleet(); def != "" { + fleetIdentifier = def + } else { + fleetIdentifier, err = pickFleet(client, ctx, projectUID) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + } + + selectedFleet, err := resolveFleet(client, ctx, projectUID, fleetIdentifier) + if err != nil { + return err + } + + return printResult(cmd, selectedFleet) + }, +} + +// 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 { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + fleetName := args[0] + + // Get optional flags + smartRule, _ := cmd.Flags().GetString("smart-rule") + connectivityAssurance, _ := cmd.Flags().GetBool("connectivity-assurance") + + // Build create request using SDK + createReq := notehub.NewCreateFleetRequest() + createReq.SetLabel(fleetName) + + if smartRule != "" { + createReq.SetSmartRule(smartRule) + } + + if cmd.Flags().Changed("connectivity-assurance") { + ca := notehub.NewFleetConnectivityAssurance() + ca.Enabled.Set(&connectivityAssurance) + createReq.SetConnectivityAssurance(*ca) + } + + // Create fleet using SDK + createdFleet, _, err := client.ProjectAPI.CreateFleet(ctx, projectUID). + CreateFleetRequest(*createReq). + Execute() + if err != nil { + return fmt.Errorf("failed to create fleet: %w", err) + } + + return printMutationResult(cmd, createdFleet, "Fleet created") + }, +} + +// 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. If no argument is provided, an interactive picker will be shown.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + var fleetIdentifier string + if len(args) > 0 { + fleetIdentifier = args[0] + } else { + fleetIdentifier, err = pickFleet(client, ctx, projectUID) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + } + + selectedFleet, err := resolveFleet(client, ctx, projectUID, fleetIdentifier) + if err != nil { + return err + } + + if err := confirmAction(cmd, fmt.Sprintf("Delete fleet '%s'?", selectedFleet.Label)); err != nil { + return nil + } + + _, err = client.ProjectAPI.DeleteFleet(ctx, projectUID, selectedFleet.Uid).Execute() + if err != nil { + return fmt.Errorf("failed to delete fleet: %w", err) + } + + return printActionResult(cmd, map[string]any{ + "action": "delete", + "fleet_uid": selectedFleet.Uid, + "fleet_name": selectedFleet.Label, + }, fmt.Sprintf("Fleet '%s' deleted", selectedFleet.Label)) + }, +} + +// 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. If no argument is provided, an interactive picker will be shown.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + var fleetIdentifier string + if len(args) > 0 { + fleetIdentifier = args[0] + } else { + fleetIdentifier, err = pickFleet(client, ctx, projectUID) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + } + + // 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("at least one update flag is required: --name, --smart-rule, --connectivity-assurance, or --watchdog-mins") + } + + selectedFleet, err := resolveFleet(client, ctx, projectUID, fleetIdentifier) + if err != nil { + return err + } + + // Build update request using SDK + updateReq := notehub.NewUpdateFleetRequest() + + if cmd.Flags().Changed("name") { + updateReq.SetLabel(newName) + } + + if cmd.Flags().Changed("smart-rule") { + updateReq.SetSmartRule(smartRule) + } + + if cmd.Flags().Changed("connectivity-assurance") { + ca := notehub.NewFleetConnectivityAssurance() + ca.Enabled.Set(&connectivityAssurance) + updateReq.SetConnectivityAssurance(*ca) + } + + if cmd.Flags().Changed("watchdog-mins") { + updateReq.SetWatchdogMins(int64(watchdogMins)) + } + + // Update fleet using SDK + updatedFleet, _, err := client.ProjectAPI.UpdateFleet(ctx, projectUID, selectedFleet.Uid). + UpdateFleetRequest(*updateReq). + Execute() + if err != nil { + return fmt.Errorf("failed to update fleet: %w", err) + } + + return printMutationResult(cmd, updatedFleet, "Fleet updated") + }, +} + +// fleetSetCmd represents the fleet set command +var fleetSetCmd = &cobra.Command{ + Use: "set [fleet-uid-or-name]", + Short: "Set the active fleet", + Long: `Set the active fleet in the configuration. You can specify either the fleet name or UID. +If no argument is provided, an interactive picker will be shown.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, ctx, projectUID, err := initCommand() + if err != nil { + return err + } + + var selectedFleet *notehub.Fleet + if len(args) > 0 { + selectedFleet, err = resolveFleet(client, ctx, projectUID, args[0]) + if err != nil { + return err + } + } else { + fleetUID, err := pickFleet(client, ctx, projectUID) + if err == errPickCancelled { + return nil + } + if err != nil { + return err + } + selectedFleet, err = resolveFleet(client, ctx, projectUID, fleetUID) + if err != nil { + return err + } + } + + return setDefault(cmd, "fleet", selectedFleet.Uid, selectedFleet.Label) + }, +} + +// fleetClearCmd represents the fleet clear command +var fleetClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear the active fleet", + Long: `Clear the active fleet from the configuration.`, + RunE: func(cmd *cobra.Command, args []string) error { + return clearDefault(cmd, "fleet", "notehub fleet set ") + }, +} + +func init() { + rootCmd.AddCommand(fleetCmd) + fleetCmd.AddCommand(fleetListCmd) + fleetCmd.AddCommand(fleetGetCmd) + fleetCmd.AddCommand(fleetCreateCmd) + fleetCmd.AddCommand(fleetDeleteCmd) + fleetCmd.AddCommand(fleetUpdateCmd) + fleetCmd.AddCommand(fleetSetCmd) + fleetCmd.AddCommand(fleetClearCmd) + + // 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)") + + addConfirmFlag(fleetDeleteCmd) +} diff --git a/notehub/cmd/helpers.go b/notehub/cmd/helpers.go new file mode 100644 index 0000000..09c3e0a --- /dev/null +++ b/notehub/cmd/helpers.go @@ -0,0 +1,1275 @@ +// Copyright 2025 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" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/blues/note-go/note" + notehub "github.com/blues/notehub-go" + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// isNetworkError checks whether an error is caused by a network connectivity +// issue (DNS resolution failure, connection refused, timeout, etc.) as opposed +// to an application-level error like invalid credentials. +func isNetworkError(err error) bool { + if err == nil { + return false + } + + // Check for DNS errors specifically + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + + // Check for connection refused / dial errors via net.OpError + var opErr *net.OpError + if errors.As(err, &opErr) { + return true + } + + // Fallback: check the error string for common network failure patterns + // (handles wrapped errors from HTTP client that may not preserve type info) + msg := err.Error() + networkPatterns := []string{ + "no such host", + "connection refused", + "network is unreachable", + "i/o timeout", + "dial tcp", + "dial udp", + "TLS handshake timeout", + "no route to host", + } + for _, pattern := range networkPatterns { + if strings.Contains(msg, pattern) { + return true + } + } + + return false +} + +// networkErrorMessage returns a user-friendly error message for network failures. +func networkErrorMessage(err error) string { + hub := GetHub() + return fmt.Sprintf("unable to connect to %s: %s\n\nPlease check your network connection and try again.", hub, err) +} + +// Shared scope flag variables used by provision, vars, and explore commands. +var ( + flagScope string + flagSn string +) + +// scopeHelpLong is shared scope format documentation appended to Long descriptions. +const scopeHelpLong = ` +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)` + +// addScopeFlag adds the standard --scope/-s flag to a command. +func addScopeFlag(cmd *cobra.Command, description string) { + cmd.Flags().StringVarP(&flagScope, "scope", "s", "", description) +} + +// confirmAction prompts the user to confirm a destructive action. Returns nil +// if confirmed, errPickCancelled if declined. Skips the prompt if --yes/-y is set. +func confirmAction(cmd *cobra.Command, message string) error { + yes, _ := cmd.Flags().GetBool("yes") + if yes { + return nil + } + + var confirmed bool + err := huh.NewConfirm(). + Title(message). + Value(&confirmed). + WithTheme(huh.ThemeBase()). + Run() + if err != nil || !confirmed { + return errPickCancelled + } + return nil +} + +// addConfirmFlag adds the --yes/-y flag to a command for skipping confirmation prompts. +func addConfirmFlag(cmd *cobra.Command) { + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +// validateAuth checks that the user is signed in and returns an error if not. +// Use this for commands that need auth but don't use the SDK client (e.g. V0 +// commands like request/trace). For commands that also need the SDK client and +// project, use initCommand() instead. +func validateAuth() error { + creds, err := GetHubCredentials() + if err != nil { + return fmt.Errorf("error getting credentials: %s", err) + } + if creds == nil || creds.Token == "" { + return fmt.Errorf("please sign in using 'notehub auth signin' or 'notehub auth signin-token'") + } + return nil +} + +// initCommand handles the common command setup: auth validation, project UID +// resolution, and SDK client/context creation. Most commands need all of these +// and previously duplicated ~15 lines of boilerplate for this setup. +func initCommand() (client *notehub.APIClient, ctx context.Context, projectUID string, err error) { + if err = validateAuth(); err != nil { + return nil, nil, "", err + } + + creds, err := GetHubCredentials() + if err != nil { + return nil, nil, "", err + } + + projectUID = GetProject() + if projectUID == "" { + return nil, nil, "", fmt.Errorf("no project set. Use 'notehub project set ' or provide --project flag") + } + + client = GetNotehubClient() + ctx = context.WithValue(context.Background(), notehub.ContextAccessToken, creds.Token) + return +} + +// wantJSON returns true if the user requested JSON or pretty-printed output. +func wantJSON() bool { + return GetJson() || GetPretty() +} + +// printJSON marshals v as JSON (compact or pretty based on flags) and prints it +// to the command's configured output writer. +func printJSON(cmd *cobra.Command, v any) error { + var output []byte + var err error + if GetPretty() { + output, err = note.JSONMarshalIndent(v, "", " ") + } else { + output, err = note.JSONMarshal(v) + } + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + cmd.Printf("%s\n", output) + return nil +} + +// printResult handles the standard output pattern for all commands: if JSON +// output is requested, print as JSON; otherwise print as human-readable text. +func printResult(cmd *cobra.Command, v any) error { + if wantJSON() { + return printJSON(cmd, v) + } + return printHuman(cmd, v) +} + +// printListResult handles the standard list output pattern: JSON if requested, +// otherwise check for empty list and show a message, or render human-readable. +// The isEmpty func checks whether the data is empty (e.g. len(resp.Items) == 0). +func printListResult(cmd *cobra.Command, v any, emptyMsg string, isEmpty func() bool) error { + if wantJSON() { + return printJSON(cmd, v) + } + if isEmpty() { + cmd.Println(emptyMsg) + return nil + } + return printHuman(cmd, v) +} + +// printMutationResult handles output for create/update mutations: JSON if requested, +// otherwise print a success message followed by the human-readable result. +func printMutationResult(cmd *cobra.Command, v any, successMsg string) error { + if wantJSON() { + return printJSON(cmd, v) + } + cmd.Println(successMsg) + return printHuman(cmd, v) +} + +// printActionResult handles output for action commands (enable, disable, delete, etc.) +// that don't return structured data from the API but should support --json output. +// The result map is printed as JSON when requested, otherwise the successMsg is shown. +func printActionResult(cmd *cobra.Command, result map[string]any, successMsg string) error { + if wantJSON() { + return printJSON(cmd, result) + } + cmd.Println(successMsg) + return nil +} + +// printHuman renders a value as human-readable key-value text. It marshals to +// JSON first (ensuring the same fields as --json) then formats the output with +// readable key names and indentation. +func printHuman(cmd *cobra.Command, v any) error { + jsonBytes, err := note.JSONMarshal(v) + if err != nil { + return fmt.Errorf("failed to marshal: %w", err) + } + + dec := json.NewDecoder(bytes.NewReader(jsonBytes)) + dec.UseNumber() + + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("failed to parse: %w", err) + } + + switch tok { + case json.Delim('{'): + humanRenderObject(cmd, dec, "") + case json.Delim('['): + humanRenderArray(cmd, dec, "") + default: + cmd.Printf("%v\n", tok) + } + return nil +} + +// humanKeyAbbreviations maps lowercase words to their uppercase abbreviations. +var humanKeyAbbreviations = map[string]string{ + "uid": "UID", "api": "API", "url": "URL", "http": "HTTP", + "sku": "SKU", "dfu": "DFU", "sn": "SN", "id": "ID", + "ip": "IP", "tls": "TLS", "imei": "IMEI", "iccid": "ICCID", + "rssi": "RSSI", "rsrp": "RSRP", "rsrq": "RSRQ", "sinr": "SINR", +} + +// humanFormatKey converts a snake_case or camelCase JSON key to Title Case, +// with common abbreviations kept uppercase. +func humanFormatKey(key string) string { + key = strings.ReplaceAll(key, "_", " ") + words := strings.Fields(key) + for i, w := range words { + lower := strings.ToLower(w) + if upper, ok := humanKeyAbbreviations[lower]; ok { + words[i] = upper + } else if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, " ") +} + +// humanRenderObject reads a JSON object from the decoder and prints it as +// indented key-value pairs. Null values and empty strings are omitted. +func humanRenderObject(cmd *cobra.Command, dec *json.Decoder, indent string) { + for dec.More() { + tok, err := dec.Token() + if err != nil { + return + } + key := humanFormatKey(tok.(string)) + + tok, err = dec.Token() + if err != nil { + return + } + + switch v := tok.(type) { + case json.Delim: + if v == '{' { + if !dec.More() { + dec.Token() // consume empty } + } else { + cmd.Printf("%s%s:\n", indent, key) + humanRenderObject(cmd, dec, indent+" ") + } + } else if v == '[' { + if !dec.More() { + dec.Token() // consume empty ] + } else { + cmd.Printf("%s%s:\n", indent, key) + humanRenderArray(cmd, dec, indent+" ") + } + } + case string: + if v != "" { + cmd.Printf("%s%s: %s\n", indent, key, v) + } + case json.Number: + cmd.Printf("%s%s: %s\n", indent, key, v.String()) + case bool: + cmd.Printf("%s%s: %v\n", indent, key, v) + case nil: + // skip null values + } + } + dec.Token() // consume } +} + +// humanRenderArray reads a JSON array from the decoder and prints its elements. +// Objects are separated by blank lines; scalars use "- value" format. +func humanRenderArray(cmd *cobra.Command, dec *json.Decoder, indent string) { + first := true + for dec.More() { + tok, err := dec.Token() + if err != nil { + return + } + + switch v := tok.(type) { + case json.Delim: + if v == '{' { + if !first { + cmd.Println() + } + first = false + humanRenderObject(cmd, dec, indent) + } else if v == '[' { + humanRenderArray(cmd, dec, indent+" ") + } + case string: + cmd.Printf("%s- %s\n", indent, v) + case json.Number: + cmd.Printf("%s- %s\n", indent, v.String()) + case bool: + cmd.Printf("%s- %v\n", indent, v) + case nil: + // skip + } + } + dec.Token() // consume ] +} + +// resolveFleet looks up a fleet by UID or name and returns the full Fleet object. +func resolveFleet(client *notehub.APIClient, ctx context.Context, projectUID, identifier string) (*notehub.Fleet, error) { + // Try direct UID lookup first + fleet, resp, err := client.ProjectAPI.GetFleet(ctx, projectUID, identifier).Execute() + if err == nil && resp != nil && resp.StatusCode != 404 { + return fleet, nil + } + + // Fall back to name search + fleetsRsp, _, err := client.ProjectAPI.GetFleets(ctx, projectUID).Execute() + if err != nil { + return nil, fmt.Errorf("failed to list fleets: %w", err) + } + + for _, f := range fleetsRsp.Fleets { + if f.Label == identifier { + return &f, nil + } + } + + return nil, fmt.Errorf("fleet '%s' not found in project", identifier) +} + +// resolveProduct looks up a product by UID or label and returns the full Product object. +func resolveProduct(client *notehub.APIClient, ctx context.Context, projectUID, identifier string) (*notehub.Product, error) { + productsRsp, _, err := client.ProjectAPI.GetProducts(ctx, projectUID).Execute() + if err != nil { + return nil, fmt.Errorf("failed to list products: %w", err) + } + + for _, p := range productsRsp.Products { + if p.Uid == identifier || p.Label == identifier { + return &p, nil + } + } + + return nil, fmt.Errorf("product '%s' not found in project", identifier) +} + +// resolveMonitor looks up a monitor by UID or name and returns its UID and name. +func resolveMonitor(client *notehub.APIClient, ctx context.Context, projectUID, identifier string) (uid string, name string, err error) { + // Try direct UID lookup first + monitor, resp, getErr := client.MonitorAPI.GetMonitor(ctx, projectUID, identifier).Execute() + if getErr == nil && resp != nil && resp.StatusCode != 404 { + if monitor.Uid != nil { + uid = *monitor.Uid + } + if monitor.Name != nil { + name = *monitor.Name + } + return uid, name, nil + } + + // Fall back to list search + monitors, _, err := client.MonitorAPI.GetMonitors(ctx, projectUID).Execute() + if err != nil { + return "", "", fmt.Errorf("failed to list monitors: %w", err) + } + + for _, m := range monitors { + mUID := "" + mName := "" + if m.Uid != nil { + mUID = *m.Uid + } + if m.Name != nil { + mName = *m.Name + } + if mUID == identifier || mName == identifier { + return mUID, mName, nil + } + } + + return "", "", fmt.Errorf("monitor '%s' not found in project", identifier) +} + +// PickerItem represents a single item in an interactive picker. +type PickerItem struct { + Label string // Display label shown to the user + Value string // Value returned when selected (e.g., UID) +} + +// errPickCancelled is returned when the user cancels an interactive picker. +// Commands should treat this as a no-op (not an error to display). +var errPickCancelled = fmt.Errorf("selection cancelled") + +// Picker navigation sentinel values. +const ( + pickerNext = "__next__" + pickerPrev = "__prev__" +) + +// PickerPage holds a page of picker items and whether more pages exist. +type PickerPage struct { + Items []PickerItem + HasMore bool +} + +// pickPaginated displays a paginated interactive picker. The fetchPage callback +// is called with the current page number (1-based) and should return items for +// that page. For non-paginated APIs, return all items with HasMore=false. +// Returns the selected item's Value, or errPickCancelled if the user cancels. +func pickPaginated(title string, emptyMsg string, fetchPage func(page int32) (PickerPage, error)) (string, error) { + pageNum := int32(1) + for { + result, err := fetchPage(pageNum) + if err != nil { + return "", err + } + if len(result.Items) == 0 && pageNum == 1 { + return "", fmt.Errorf("%s", emptyMsg) + } + + // Build picker items with navigation + items := make([]PickerItem, 0, len(result.Items)+2) + if pageNum > 1 { + items = append(items, PickerItem{Label: "← Previous page", Value: pickerPrev}) + } + items = append(items, result.Items...) + if result.HasMore { + items = append(items, PickerItem{Label: "Next page →", Value: pickerNext}) + } + + // Show picker + pickerTitle := title + if result.HasMore || pageNum > 1 { + pickerTitle = fmt.Sprintf("%s (page %d)", title, pageNum) + } + + options := make([]huh.Option[int], len(items)) + for i, item := range items { + options[i] = huh.NewOption(item.Label, i) + } + + theme := huh.ThemeBase() + var selected int + err = huh.NewSelect[int](). + Title(pickerTitle). + Options(options...). + Value(&selected). + WithTheme(theme). + Run() + if err != nil { + return "", errPickCancelled + } + + switch items[selected].Value { + case pickerNext: + pageNum++ + case pickerPrev: + pageNum-- + default: + return items[selected].Value, nil + } + } +} + +// resolveDeviceArg returns a device UID from the command args, the --device flag, +// or an interactive picker if neither is provided. +func resolveDeviceArg(client *notehub.APIClient, ctx context.Context, projectUID string, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if d := GetDevice(); d != "" { + return d, nil + } + return pickDevice(client, ctx, projectUID) +} + +// pickDevice presents a paginated device picker. +func pickDevice(client *notehub.APIClient, ctx context.Context, projectUID string) (string, error) { + return pickPaginated("Select a device", "no devices found in this project", func(page int32) (PickerPage, error) { + devicesResp, _, err := client.DeviceAPI.GetDevices(ctx, projectUID). + PageSize(50). + PageNum(page). + Execute() + if err != nil { + return PickerPage{}, fmt.Errorf("failed to list devices: %w", err) + } + items := make([]PickerItem, len(devicesResp.Devices)) + for i, d := range devicesResp.Devices { + label := d.Uid + if d.SerialNumber != nil && *d.SerialNumber != "" { + label = fmt.Sprintf("%s (%s)", d.Uid, *d.SerialNumber) + } + items[i] = PickerItem{Label: label, Value: d.Uid} + } + return PickerPage{Items: items, HasMore: devicesResp.HasMore}, nil + }) +} + +// pickFleet presents a fleet picker. +func pickFleet(client *notehub.APIClient, ctx context.Context, projectUID string) (string, error) { + return pickPaginated("Select a fleet", "no fleets found in this project. Create one with 'notehub fleet create '", func(page int32) (PickerPage, error) { + fleetsRsp, _, err := client.ProjectAPI.GetFleets(ctx, projectUID).Execute() + if err != nil { + return PickerPage{}, fmt.Errorf("failed to list fleets: %w", err) + } + items := make([]PickerItem, len(fleetsRsp.Fleets)) + for i, f := range fleetsRsp.Fleets { + items[i] = PickerItem{Label: f.Label, Value: f.Uid} + } + return PickerPage{Items: items, HasMore: false}, nil + }) +} + +// pickRoute presents a route picker. +func pickRoute(client *notehub.APIClient, ctx context.Context, projectUID string) (string, error) { + return pickPaginated("Select a route", "no routes found in this project. Create one with 'notehub route create