diff --git a/go.mod b/go.mod index 3881ebf..c6d0986 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1 connectrpc.com/connect v1.19.1 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 github.com/joho/godotenv v1.5.1 @@ -27,19 +28,41 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 // 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.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.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.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/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect github.com/olekukonko/ll v0.0.8 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - golang.org/x/sys v0.26.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c80a9e2..e4ac7cd 100644 --- a/go.sum +++ b/go.sum @@ -8,13 +8,57 @@ buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be68 buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +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/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +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.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -37,17 +81,29 @@ github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +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/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +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/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +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/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= @@ -56,8 +112,9 @@ github.com/olekukonko/tablewriter v1.0.7 h1:HCC2e3MM+2g72M81ZcJU11uciw6z/p82aEnm github.com/olekukonko/tablewriter v1.0.7/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= 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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 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/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -75,12 +132,21 @@ github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1 github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= github.com/urfave/cli/v3 v3.0.0-alpha9.2 h1:CL8llQj3dGRLVQQzHxS+ZYRLanOuhyK1fXgLKD+qV+Y= github.com/urfave/cli/v3 v3.0.0-alpha9.2/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= +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= +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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/cli/confirmation.go b/internal/cli/confirmation.go index 0cc90c3..2fe1faf 100644 --- a/internal/cli/confirmation.go +++ b/internal/cli/confirmation.go @@ -5,12 +5,10 @@ import ( "fmt" "os" "strings" - - "github.com/mattn/go-isatty" ) var isInteractiveStdin = func() bool { - return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) + return IsStdinTerminal() } func AskForConfirmation(s string) (bool, error) { diff --git a/internal/cli/output.go b/internal/cli/output.go index 4cfe39a..505ce2c 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -22,7 +22,10 @@ func SetDebugMode(v bool) { debugMode.Store(v) } func IsJSONOutput() bool { return jsonOutput.Load() } func IsQuiet() bool { return quietMode.Load() } func IsDebug() bool { return debugMode.Load() } -func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } +func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } +func IsStdinTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) +} func IsStderrTerminal() bool { return isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) } diff --git a/internal/statusreport/statusreport_add_update.go b/internal/statusreport/statusreport_add_update.go index 6bbbf78..3209f72 100644 --- a/internal/statusreport/statusreport_add_update.go +++ b/internal/statusreport/statusreport_add_update.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" @@ -77,14 +78,12 @@ func GetStatusReportAddUpdateCmd() *cli.Command { Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ - Name: "status", - Usage: "New status (investigating, identified, monitoring, resolved)", - Required: true, + Name: "status", + Usage: "New status (investigating, identified, monitoring, resolved)", }, &cli.StringFlag{ - Name: "message", - Usage: "Message describing what changed", - Required: true, + Name: "message", + Usage: "Message describing what changed", }, &cli.StringFlag{ Name: "date", @@ -100,7 +99,36 @@ func GetStatusReportAddUpdateCmd() *cli.Command { if err != nil { return cli.Exit(err.Error(), 1) } - reportId := cmd.Args().Get(0) + + inputs := &addUpdateInputs{ + ReportID: cmd.Args().Get(0), + Status: cmd.String("status"), + Message: cmd.String("message"), + Notify: cmd.Bool("notify"), + } + + needsWizard := inputs.ReportID == "" || inputs.Status == "" || + inputs.Message == "" + + if needsWizard { + if output.IsJSONOutput() || !output.IsStdinTerminal() { + var missing []string + if inputs.ReportID == "" { + missing = append(missing, "") + } + if inputs.Status == "" { + missing = append(missing, "--status") + } + if inputs.Message == "" { + missing = append(missing, "--message") + } + return cli.Exit(fmt.Sprintf("missing required arguments: %s", strings.Join(missing, ", ")), 1) + } + inputs, err = runAddUpdateWizard(ctx, apiKey, inputs) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } date := cmd.String("date") if date == "" { @@ -112,11 +140,11 @@ func GetStatusReportAddUpdateCmd() *cli.Command { err = AddStatusReportUpdate( ctx, client, - reportId, - cmd.String("status"), - cmd.String("message"), + inputs.ReportID, + inputs.Status, + inputs.Message, date, - cmd.Bool("notify"), + inputs.Notify, s, ) if err != nil { diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go index a8facdb..473b6c7 100644 --- a/internal/statusreport/statusreport_create.go +++ b/internal/statusreport/statusreport_create.go @@ -62,24 +62,20 @@ func GetStatusReportCreateCmd() *cli.Command { Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ - Name: "title", - Usage: "Title of the status report", - Required: true, + Name: "title", + Usage: "Title of the status report", }, &cli.StringFlag{ - Name: "status", - Usage: "Initial status (investigating, identified, monitoring, resolved)", - Required: true, + Name: "status", + Usage: "Initial status (investigating, identified, monitoring, resolved)", }, &cli.StringFlag{ - Name: "message", - Usage: "Initial message describing the incident", - Required: true, + Name: "message", + Usage: "Initial message describing the incident", }, &cli.StringFlag{ - Name: "page-id", - Usage: "Status page ID to associate with this report", - Required: true, + Name: "page-id", + Usage: "Status page ID to associate with this report", }, &cli.StringFlag{ Name: "component-ids", @@ -99,28 +95,61 @@ func GetStatusReportCreateCmd() *cli.Command { if err != nil { return cli.Exit(err.Error(), 1) } + + inputs := &createInputs{ + PageID: cmd.String("page-id"), + Title: cmd.String("title"), + Status: cmd.String("status"), + Message: cmd.String("message"), + Notify: cmd.Bool("notify"), + } + if ids := cmd.String("component-ids"); ids != "" { + inputs.ComponentIDs = strings.Split(ids, ",") + } + + needsWizard := inputs.Title == "" || inputs.Status == "" || + inputs.Message == "" || inputs.PageID == "" + + if needsWizard { + if output.IsJSONOutput() || !output.IsStdinTerminal() { + var missing []string + if inputs.Title == "" { + missing = append(missing, "--title") + } + if inputs.Status == "" { + missing = append(missing, "--status") + } + if inputs.Message == "" { + missing = append(missing, "--message") + } + if inputs.PageID == "" { + missing = append(missing, "--page-id") + } + return cli.Exit(fmt.Sprintf("missing required flags: %s", strings.Join(missing, ", ")), 1) + } + inputs, err = runCreateWizard(ctx, apiKey, inputs) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } + date := cmd.String("date") if date == "" { date = time.Now().UTC().Format(time.RFC3339) } - var componentIds []string - if ids := cmd.String("component-ids"); ids != "" { - componentIds = strings.Split(ids, ",") - } - client := NewStatusReportClient(apiKey) s := output.StartSpinner("Creating status report...") id, err := CreateStatusReport( ctx, client, - cmd.String("title"), - cmd.String("status"), - cmd.String("message"), + inputs.Title, + inputs.Status, + inputs.Message, date, - cmd.String("page-id"), - componentIds, - cmd.Bool("notify"), + inputs.PageID, + inputs.ComponentIDs, + inputs.Notify, ) output.StopSpinner(s) if err != nil { diff --git a/internal/statusreport/statusreport_wizard.go b/internal/statusreport/statusreport_wizard.go new file mode 100644 index 0000000..aeb06cf --- /dev/null +++ b/internal/statusreport/statusreport_wizard.go @@ -0,0 +1,418 @@ +package statusreport + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" + "connectrpc.com/connect" + "github.com/charmbracelet/huh" + "github.com/openstatusHQ/cli/internal/api" + output "github.com/openstatusHQ/cli/internal/cli" +) + +type createInputs struct { + PageID string + PageName string + Title string + Status string + Message string + ComponentIDs []string + componentNames map[string]string // component ID -> display name, for summary + Notify bool + Confirmed bool +} + +type addUpdateInputs struct { + ReportID string + ReportName string + Status string + Message string + Notify bool + Confirmed bool +} + +func notEmpty(fieldName string) func(string) error { + return func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("%s cannot be empty", fieldName) + } + return nil + } +} + +func handleFormError(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(130) + } + return err +} + +func statusSelectOptions() []huh.Option[string] { + return []huh.Option[string]{ + huh.NewOption("Investigating", "investigating"), + huh.NewOption("Identified", "identified"), + huh.NewOption("Monitoring", "monitoring"), + huh.NewOption("Resolved", "resolved"), + } +} + +func buildSummary(lines [][2]string) string { + var sb strings.Builder + for _, line := range lines { + sb.WriteString(fmt.Sprintf(" %s: %s\n", line[0], line[1])) + } + return sb.String() +} + +// Data-fetching functions + +func fetchStatusPages(ctx context.Context, apiKey string) ([]*status_pagev1.StatusPageSummary, error) { + client := status_pagev1connect.NewStatusPageServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) + resp, err := client.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) + if err != nil { + return nil, output.FormatError(err, "status-page", "") + } + return resp.GetStatusPages(), nil +} + +func fetchPageComponents(ctx context.Context, apiKey string, pageID string) ([]*status_pagev1.PageComponent, []*status_pagev1.PageComponentGroup, error) { + client := status_pagev1connect.NewStatusPageServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) + req := &status_pagev1.GetStatusPageContentRequest{} + req.SetId(pageID) + resp, err := client.GetStatusPageContent(ctx, req) + if err != nil { + return nil, nil, output.FormatError(err, "status-page", pageID) + } + return resp.GetComponents(), resp.GetGroups(), nil +} + +func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1.StatusReportSummary, error) { + client := NewStatusReportClient(apiKey) + + // Try unresolved reports first + req := &status_reportv1.ListStatusReportsRequest{} + l := int32(20) + req.SetLimit(l) + req.SetStatuses([]status_reportv1.StatusReportStatus{ + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_INVESTIGATING, + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_IDENTIFIED, + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_MONITORING, + }) + + resp, err := client.ListStatusReports(ctx, req) + if err != nil { + return nil, output.FormatError(err, "status-report", "") + } + + reports := resp.GetStatusReports() + if len(reports) > 0 { + return reports, nil + } + + // Fall back to all recent reports + reqAll := &status_reportv1.ListStatusReportsRequest{} + reqAll.SetLimit(l) + resp, err = client.ListStatusReports(ctx, reqAll) + if err != nil { + return nil, output.FormatError(err, "status-report", "") + } + return resp.GetStatusReports(), nil +} + +// Wizard: sr create + +func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs) (*createInputs, error) { + inputs := *prefilled + + // Fetch status pages + s := output.StartSpinner("Fetching status pages...") + pages, err := fetchStatusPages(ctx, apiKey) + output.StopSpinner(s) + if err != nil { + return nil, err + } + if len(pages) == 0 { + return nil, fmt.Errorf("no status pages found. Create one at https://www.openstatus.dev first, then run this command again") + } + + // Form 1: page select (if needed) + if inputs.PageID == "" { + pageOptions := make([]huh.Option[string], 0, len(pages)) + for _, p := range pages { + label := p.GetTitle() + " (" + statusPageURL(p) + ")" + pageOptions = append(pageOptions, huh.NewOption(label, p.GetId())) + } + + form1 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Status page"). + Options(pageOptions...). + Value(&inputs.PageID), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form1.Run(); err != nil { + return nil, handleFormError(err) + } + } + + // Resolve page name for summary + for _, p := range pages { + if p.GetId() == inputs.PageID { + inputs.PageName = p.GetTitle() + break + } + } + + // Fetch components for selected page + s = output.StartSpinner("Fetching page components...") + components, groups, err := fetchPageComponents(ctx, apiKey, inputs.PageID) + output.StopSpinner(s) + if err != nil { + return nil, err + } + + // Build Form 2 fields dynamically + var fields []huh.Field + + // Build component name map for summary display + if len(components) > 0 { + groupMap := make(map[string]string, len(groups)) + for _, g := range groups { + groupMap[g.GetId()] = g.GetName() + } + + inputs.componentNames = make(map[string]string, len(components)) + compOptions := make([]huh.Option[string], 0, len(components)) + for _, c := range components { + label := c.GetName() + if gid := c.GetGroupId(); gid != "" { + if gname, ok := groupMap[gid]; ok { + label += " (" + gname + ")" + } + } + inputs.componentNames[c.GetId()] = label + compOptions = append(compOptions, huh.NewOption(label, c.GetId())) + } + + // Component multi-select (only if not prefilled) + if len(prefilled.ComponentIDs) == 0 { + fields = append(fields, huh.NewMultiSelect[string](). + Title("Components"). + Options(compOptions...). + Value(&inputs.ComponentIDs)) + } + } + + if inputs.Title == "" { + fields = append(fields, huh.NewInput(). + Title("Title"). + Validate(notEmpty("title")). + Value(&inputs.Title)) + } + + if inputs.Status == "" { + fields = append(fields, huh.NewSelect[string](). + Title("Status"). + Options(statusSelectOptions()...). + Value(&inputs.Status)) + } + + if inputs.Message == "" { + fields = append(fields, huh.NewText(). + Title("Message"). + Validate(notEmpty("message")). + Value(&inputs.Message)) + } + + fields = append(fields, huh.NewConfirm(). + Title("Notify subscribers?"). + Value(&inputs.Notify)) + + // Summary + confirm group + summaryNote := huh.NewNote(). + Title("Summary"). + DescriptionFunc(func() string { + lines := [][2]string{ + {"Page", inputs.PageName}, + } + if len(inputs.ComponentIDs) > 0 { + names := make([]string, 0, len(inputs.ComponentIDs)) + for _, id := range inputs.ComponentIDs { + if name, ok := inputs.componentNames[id]; ok { + names = append(names, name) + } else { + names = append(names, id) + } + } + lines = append(lines, [2]string{"Components", strings.Join(names, ", ")}) + } + lines = append(lines, + [2]string{"Title", inputs.Title}, + [2]string{"Status", inputs.Status}, + [2]string{"Message", inputs.Message}, + ) + notifyStr := "no" + if inputs.Notify { + notifyStr = "yes" + } + lines = append(lines, [2]string{"Notify", notifyStr}) + return buildSummary(lines) + }, &inputs) + + form2 := huh.NewForm( + huh.NewGroup(fields...), + huh.NewGroup( + summaryNote, + huh.NewConfirm(). + Title("Create this status report?"). + Value(&inputs.Confirmed), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form2.Run(); err != nil { + return nil, handleFormError(err) + } + + if !inputs.Confirmed { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(130) + } + + return &inputs, nil +} + +// Wizard: sr add-update + +func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdateInputs) (*addUpdateInputs, error) { + inputs := *prefilled + + // Fetch reports for selection or name resolution + s := output.StartSpinner("Fetching status reports...") + reports, err := fetchStatusReports(ctx, apiKey) + output.StopSpinner(s) + if err != nil { + return nil, err + } + + if inputs.ReportID == "" { + // Form 1: report select + if len(reports) == 0 { + return nil, fmt.Errorf("no status reports found") + } + + reportOptions := make([]huh.Option[string], 0, len(reports)) + for _, r := range reports { + label := r.GetTitle() + " (" + statusToString(r.GetStatus()) + ")" + reportOptions = append(reportOptions, huh.NewOption(label, r.GetId())) + } + + form1 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a status report"). + Options(reportOptions...). + Value(&inputs.ReportID), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form1.Run(); err != nil { + return nil, handleFormError(err) + } + } + + // Resolve report name for summary + for _, r := range reports { + if r.GetId() == inputs.ReportID { + inputs.ReportName = r.GetTitle() + break + } + } + if inputs.ReportName == "" { + inputs.ReportName = inputs.ReportID + } + + // Build Form 2 fields dynamically + var fields []huh.Field + + if inputs.Status == "" { + fields = append(fields, huh.NewSelect[string](). + Title("Status"). + Options(statusSelectOptions()...). + Value(&inputs.Status)) + } + + if inputs.Message == "" { + fields = append(fields, huh.NewInput(). + Title("Message"). + Validate(notEmpty("message")). + Value(&inputs.Message)) + } + + fields = append(fields, huh.NewConfirm(). + Title("Notify subscribers?"). + Value(&inputs.Notify)) + + summaryNote := huh.NewNote(). + Title("Summary"). + DescriptionFunc(func() string { + lines := [][2]string{ + {"Report", inputs.ReportName}, + {"Status", inputs.Status}, + {"Message", inputs.Message}, + } + notifyStr := "no" + if inputs.Notify { + notifyStr = "yes" + } + lines = append(lines, [2]string{"Notify", notifyStr}) + return buildSummary(lines) + }, &inputs) + + form2 := huh.NewForm( + huh.NewGroup(fields...), + huh.NewGroup( + summaryNote, + huh.NewConfirm(). + Title("Add this update?"). + Value(&inputs.Confirmed), + ), + ).WithTheme(huh.ThemeBase()) + + if err := form2.Run(); err != nil { + return nil, handleFormError(err) + } + + if !inputs.Confirmed { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(130) + } + + return &inputs, nil +} + +func statusPageURL(p *status_pagev1.StatusPageSummary) string { + if d := p.GetCustomDomain(); d != "" { + d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") + return "https://" + d + } + return "https://" + p.GetSlug() + ".openstatus.dev" +} diff --git a/internal/statusreport/statusreport_wizard_test.go b/internal/statusreport/statusreport_wizard_test.go new file mode 100644 index 0000000..9ae1ed1 --- /dev/null +++ b/internal/statusreport/statusreport_wizard_test.go @@ -0,0 +1,80 @@ +package statusreport + +import ( + "testing" +) + +func Test_notEmpty(t *testing.T) { + t.Parallel() + + validator := notEmpty("title") + + t.Run("empty string returns error", func(t *testing.T) { + if err := validator(""); err == nil { + t.Error("expected error for empty string") + } + }) + + t.Run("whitespace-only returns error", func(t *testing.T) { + if err := validator(" "); err == nil { + t.Error("expected error for whitespace-only string") + } + }) + + t.Run("non-empty string returns nil", func(t *testing.T) { + if err := validator("hello"); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("error message contains field name", func(t *testing.T) { + err := validator("") + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "title cannot be empty" { + t.Errorf("expected 'title cannot be empty', got %q", err.Error()) + } + }) +} + +func Test_buildSummary(t *testing.T) { + t.Parallel() + + lines := [][2]string{ + {"Page", "Production"}, + {"Status", "investigating"}, + } + result := buildSummary(lines) + if result == "" { + t.Error("expected non-empty summary") + } + if !contains(result, "Page") || !contains(result, "Production") { + t.Errorf("summary missing expected content: %s", result) + } + if !contains(result, "Status") || !contains(result, "investigating") { + t.Errorf("summary missing expected content: %s", result) + } +} + +func Test_statusSelectOptions(t *testing.T) { + t.Parallel() + + opts := statusSelectOptions() + if len(opts) != 4 { + t.Errorf("expected 4 options, got %d", len(opts)) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}