diff --git a/go.mod b/go.mod index b5d3c77d4..b6646374e 100644 --- a/go.mod +++ b/go.mod @@ -46,10 +46,14 @@ require ( require github.com/hashicorp/go-version v1.6.0 require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.3 + github.com/charmbracelet/lipgloss v0.11.0 github.com/cli/browser v1.3.0 github.com/dustin/go-humanize v1.0.1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/hashicorp/go-retryablehttp v0.7.5 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/reubenmiller/gojsonq/v2 v2.0.0-20221119213524-0fd921ac20a3 ) @@ -57,14 +61,20 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alecthomas/chroma v0.10.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/boombuler/barcode v1.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect github.com/creack/pty v1.1.18 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -72,18 +82,20 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/microcosm-cc/bluemonday v1.0.26 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -92,11 +104,13 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.6.0 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index f9ee2315a..9bd625477 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +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 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 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= @@ -20,8 +22,22 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.26.3 h1:iXyGvI+FfOWqkB2V07m1DF3xxQijxjY2j8PqiXYqasg= +github.com/charmbracelet/bubbletea v0.26.3/go.mod h1:bpZHfDHTYJC5g+FBK+ptJRCQotRC+Dhh3AoMxa/2+3Q= github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= +github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -51,6 +67,8 @@ github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -116,6 +134,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd 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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -134,6 +154,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= @@ -163,8 +187,8 @@ github.com/reubenmiller/gojsonq/v2 v2.0.0-20221119213524-0fd921ac20a3 h1:v8Q77Ob github.com/reubenmiller/gojsonq/v2 v2.0.0-20221119213524-0fd921ac20a3/go.mod h1:QidmUT4ebNVwyjKXAQgx9VFHxpOxBKWs32EEXaXnEfE= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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= @@ -220,6 +244,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/vbauerster/mpb/v6 v6.0.4 h1:h6J5zM/2wimP5Hj00unQuV8qbo5EPcj6wbkCqgj7KcY= github.com/vbauerster/mpb/v6 v6.0.4/go.mod h1:a/+JT57gqh6Du0Ay5jSR+uBMfXGdlR7VQlGP52fJxLM= +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/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -257,6 +283,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -265,6 +293,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/c8ylogin/c8ylogin.go b/pkg/c8ylogin/c8ylogin.go index 05c694bbd..71b1f75ac 100644 --- a/pkg/c8ylogin/c8ylogin.go +++ b/pkg/c8ylogin/c8ylogin.go @@ -206,7 +206,7 @@ func (lh *LoginHandler) init() { lh.Logger.Infof("Login options returned 401. This may happen when your c8y hostname is not setup correctly") return nil } - lh.Logger.Warnf("Failed to get login options") + lh.Logger.Warnf("Failed to get login options. %s", err) return err } if loginOptions == nil { diff --git a/pkg/c8ysession/c8ysession.go b/pkg/c8ysession/c8ysession.go index f293d8664..582315bf1 100644 --- a/pkg/c8ysession/c8ysession.go +++ b/pkg/c8ysession/c8ysession.go @@ -1,14 +1,17 @@ package c8ysession import ( + "encoding/json" "fmt" "io" + "net/url" "os" "strings" "github.com/fatih/color" "github.com/reubenmiller/go-c8y-cli/v2/pkg/config" "github.com/reubenmiller/go-c8y-cli/v2/pkg/logger" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/utilities" "github.com/reubenmiller/go-c8y/pkg/c8y" ) @@ -26,6 +29,7 @@ type CumulocitySession struct { Version string `json:"version"` Username string `json:"username"` Password string `json:"password"` + TOTP string `json:"totp"` Token string `json:"token"` Description string `json:"description"` UseTenantPrefix bool `json:"useTenantPrefix"` @@ -39,6 +43,9 @@ type CumulocitySession struct { Extension string `json:"-"` Name string `json:"-"` + // How to identify the session + SessionUri string `json:"sessionUri"` + Logger *logger.Logger `json:"-"` Config *config.Config `json:"-"` } @@ -83,6 +90,27 @@ func (s CumulocitySession) GetPassword() string { return pass } +// GetDomain gets the custom Cumulocity domain for cases where it differs from the Host +func (s CumulocitySession) GetDomain() string { + host := s.Host + if !strings.Contains(host, "://") { + host = "https://" + host + } + if domain, err := url.Parse(host); err == nil { + return domain.Host + } + return s.Host +} + +func PrintSessionInfoAsJSON(w io.Writer, client *c8y.Client, cfg *config.Config, session CumulocitySession) error { + out, err := json.Marshal(session) + if err != nil { + return err + } + fmt.Fprintf(w, "%s\n", out) + return nil +} + // PrintSessionInfo print out the session information to writer (i.e. console or file) func PrintSessionInfo(w io.Writer, client *c8y.Client, cfg *config.Config, session CumulocitySession) { labelS := color.New(color.FgWhite, color.Faint) @@ -91,7 +119,11 @@ func PrintSessionInfo(w io.Writer, client *c8y.Client, cfg *config.Config, sessi header := color.New(color.FgCyan).SprintFunc() labelS.Fprintf(w, "--------------------- Cumulocity Session ---------------------\n") - fmt.Fprintf(w, "\n %s: %s\n\n\n", label("%s", "path"), header(cfg.HideSensitiveInformationIfActive(client, session.Path))) + if session.SessionUri != "" { + fmt.Fprintf(w, "\n %s: %s\n\n\n", label("%s", "source"), header(cfg.HideSensitiveInformationIfActive(client, session.SessionUri))) + } else { + fmt.Fprintf(w, "\n %s: %s\n\n\n", label("%s", "path"), header(cfg.HideSensitiveInformationIfActive(client, session.Path))) + } if session.Description != "" { fmt.Fprintf(w, "%s : %s\n", label(fmt.Sprintf("%-12s", "description")), value(cfg.HideSensitiveInformationIfActive(client, session.Host))) } @@ -106,3 +138,116 @@ func PrintSessionInfo(w io.Writer, client *c8y.Client, cfg *config.Config, sessi fmt.Fprintf(w, "%s : %s\n", label(fmt.Sprintf("%-12s", "username")), value(cfg.HideSensitiveInformationIfActive(client, session.Username))) fmt.Fprintf(w, "\n") } + +func WriteOutput(w io.Writer, client *c8y.Client, cfg *config.Config, session *CumulocitySession, format string) error { + + shell, isShell := utilities.ShellType.Parse(utilities.ShellBash, format) + if isShell { + output := GetVariablesFromSession(session, client, cfg.AlwaysIncludePassword()) + utilities.WriteShellVariables(w, output, shell) + return nil + } + + if format == "" { + return nil + } + + switch format { + case "json": + out, err := json.Marshal(session) + if err != nil { + return err + } + fmt.Fprintf(w, "%s\n", out) + case "env", "dotenv": + output := GetVariablesFromSession(session, client, cfg.AlwaysIncludePassword()) + for k, v := range output { + if v != "" { + fmt.Fprintf(w, "%s=%s\n", k, v) + } + } + default: + return fmt.Errorf("unsupported output format. %s", format) + } + return nil +} + +// GetVariablesFromSession gets all the environment variables associated with the current session +func GetVariablesFromSession(session *CumulocitySession, client *c8y.Client, setPassword bool) map[string]interface{} { + host := session.Host + domain := session.GetDomain() + tenant := session.Tenant + c8yVersion := client.Version + username := session.Username + password := session.Password + token := session.Token + authHeaderValue := "" + authHeader := "" + + if dummyReq, err := client.NewRequest("GET", "/", "", nil); err == nil { + authHeaderValue = dummyReq.Header.Get("Authorization") + authHeader = "Authorization: " + authHeaderValue + } + + // hide password if it is not needed + if !setPassword && token != "" { + password = "" + } + + output := map[string]interface{}{ + // "C8Y_SESSION": c.GetSessionFile(), + "C8Y_URL": host, + "C8Y_BASEURL": host, + "C8Y_HOST": host, + "C8Y_DOMAIN": domain, + "C8Y_TENANT": tenant, + "C8Y_VERSION": c8yVersion, + "C8Y_USER": username, + "C8Y_TOKEN": token, + "C8Y_USERNAME": username, + "C8Y_PASSWORD": password, + "C8Y_HEADER_AUTHORIZATION": authHeaderValue, + "C8Y_HEADER": authHeader, + } + return output +} + +func ShowClientEnvironmentVariables(cfg *config.Config, c8yclient *c8y.Client, shell utilities.ShellType) { + output := cfg.GetEnvironmentVariables(c8yclient, cfg.AlwaysIncludePassword()) + utilities.WriteShellVariables(os.Stdout, output, shell) +} + +func ShowSessionEnvironmentVariables(session *CumulocitySession, cfg *config.Config, c8yclient *c8y.Client, shell utilities.ShellType) { + output := GetVariablesFromSession(session, c8yclient, cfg.AlwaysIncludePassword()) + utilities.WriteShellVariables(os.Stdout, output, shell) +} + +func GetSessionEnvKeys() []string { + keys := []string{ + "C8Y_HOST", + "C8Y_URL", + "C8Y_BASEURL", + "C8Y_DOMAIN", + "C8Y_TENANT", + "C8Y_USER", + "C8Y_USERNAME", + "C8Y_PASSWORD", + "C8Y_TOKEN", + "C8Y_VERSION", + "C8Y_SESSION", + "C8Y_HEADER", + "C8Y_HEADER_AUTHORIZATION", + "C8Y_SETTINGS_MODE_ENABLECREATE", + "C8Y_SETTINGS_MODE_ENABLEUPDATE", + "C8Y_SETTINGS_MODE_ENABLEDELETE", + } + return keys +} + +func ClearEnvironmentVariables(shell utilities.ShellType) { + utilities.ClearEnvironmentVariables(GetSessionEnvKeys(), shell) +} + +func ClearProcessEnvironment() { + utilities.ClearProcessEnvironment(GetSessionEnvKeys()) +} diff --git a/pkg/cmd/sessions/clear/clear.manual.go b/pkg/cmd/sessions/clear/clear.manual.go index 398aa2073..d24da6c86 100644 --- a/pkg/cmd/sessions/clear/clear.manual.go +++ b/pkg/cmd/sessions/clear/clear.manual.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc/v2" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/c8ysession" "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/subcommand" "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmdutil" "github.com/reubenmiller/go-c8y-cli/v2/pkg/completion" @@ -62,6 +63,6 @@ func (n *CmdClearSession) RunE(cmd *cobra.Command, args []string) error { if strings.EqualFold(n.Shell, "auto") { n.Shell = shell.DetectShell("bash") } - utilities.ClearEnvironmentVariables(shellType.FromString(n.Shell)) + c8ysession.ClearEnvironmentVariables(shellType.FromString(n.Shell)) return nil } diff --git a/pkg/cmd/sessions/login/login.manual.go b/pkg/cmd/sessions/login/login.manual.go new file mode 100644 index 000000000..c651601e4 --- /dev/null +++ b/pkg/cmd/sessions/login/login.manual.go @@ -0,0 +1,382 @@ +package login + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/kballard/go-shellquote" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/c8ylogin" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/c8ysession" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/subcommand" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmderrors" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmdutil" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/completion" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/config" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/jsonUtilities" + "github.com/reubenmiller/go-c8y-cli/v2/pkg/shell" + "github.com/reubenmiller/go-c8y/pkg/c8y" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type CmdLogin struct { + // Sources + File string + Exec string + Stdin bool + Env bool + Format string + Provider string + + // Login options + LoginType string + + // Output options + Shell string + OutputFormat string + NoBanner bool + + *subcommand.SubCommand + + factory *cmdutil.Factory +} + +var ( + ProviderTypeAuto = "auto" + ProviderTypeFile = "file" + ProviderTypeEnv = "env" + ProviderTypeExternal = "external" + ProviderTypeStdin = "stdin" +) + +func NewCmdLogin(f *cmdutil.Factory) *CmdLogin { + ccmd := &CmdLogin{ + factory: f, + } + + cmd := &cobra.Command{ + Use: "login", + Short: "login to Cumulocity IoT and return environment variables (including a token)", + Long: `Set a session, login and test the session and get either OAuth2 token, or using two factor authentication`, + Example: heredoc.Doc(` + $ eval "$( c8y sessions login --from-file .env )" + Set a session from a dotenv file + + $ eval "$( c8y sessions login --from-env )" + Set a session from environment variables (e.g. in Github) + + $ eval "$( c8y-session-bitwarden | c8y sessions login --from-stdin --format json )" + Set a session from an external command, accepting the selected session via stdin + + $ eval "$( c8y sessions login --from-cmd "c8y sessions set --output json" )" + Set a session using the in-built "c8y sessions set" + + $ eval "$( c8y sessions login --from-cmd "c8y-session-bitwarden list --folder c8y" --format json )" + Set a session from an external command, where the external commands returns the selected session in json format on stdout + `), + RunE: ccmd.RunE, + } + + cmd.SilenceUsage = true + + cmd.Flags().StringVar(&ccmd.Provider, "provider", "", "Session provider which returns the session to use") + cmd.Flags().StringVar(&ccmd.File, "from-file", "", "Read session from a file") + cmd.Flags().StringVar(&ccmd.Exec, "from-cmd", "", "External command to execute to get the log in details") + cmd.Flags().BoolVar(&ccmd.Env, "from-env", false, "Read from environment variables") + cmd.Flags().BoolVar(&ccmd.Stdin, "from-stdin", false, "Read from standard input") + cmd.Flags().BoolVar(&ccmd.NoBanner, "no-banner", false, "Don't show the session banner") + cmd.Flags().StringVar(&ccmd.Format, "format", "", "External command format, e.g. json, yaml, toml") + cmd.Flags().StringVar(&ccmd.OutputFormat, "output-format", "", "Output format") + cmd.Flags().StringVar(&ccmd.Shell, "shell", "", "Shell type to return the environment variables") + cmd.Flags().StringVar(&ccmd.LoginType, "loginType", "", "Login type preference, e.g. OAUTH2_INTERNAL or BASIC. When set to BASIC, any existing token will be cleared") + + completion.WithOptions( + cmd, + completion.WithValidateSet("shell", "auto", "bash", "zsh", "fish", "powershell"), + completion.WithValidateSet("output-format", "json", "dotenv"), + completion.WithValidateSet("provider", ProviderTypeFile, ProviderTypeStdin, ProviderTypeEnv, ProviderTypeExternal, ProviderTypeAuto), + completion.WithValidateSet("format", "json", "yaml", "toml", "dotenv"), + completion.WithValidateSet("loginType", c8y.AuthMethodOAuth2Internal, c8y.AuthMethodBasic), + ) + ccmd.SubCommand = subcommand.NewSubCommand(cmd) + + cmd.MarkFlagsMutuallyExclusive("from-file", "from-cmd", "from-stdin", "from-env") + cmd.MarkFlagsMutuallyExclusive("output-format", "shell") + + return ccmd +} + +func (n *CmdLogin) FromStdin(format string, args []string) (*c8ysession.CumulocitySession, error) { + session, err := n.FromReader(bufio.NewReader(os.Stdin), format) + + if session.SessionUri == "" { + session.SessionUri = "stdin://host" + } + return session, err +} + +func (n *CmdLogin) FromEnv() (*c8ysession.CumulocitySession, error) { + session := &c8ysession.CumulocitySession{ + Tenant: os.Getenv("C8Y_TENANT"), + Password: os.Getenv("C8Y_PASSWORD"), + Token: os.Getenv("C8Y_TOKEN"), + } + + // Choose the first non-empty value + hostAliases := []string{ + "C8Y_HOST", + "C8Y_URL", + "C8Y_BASEURL", + } + for _, k := range hostAliases { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + session.SetHost(v) + break + } + } + + // Username + usernameAliases := []string{ + "C8Y_USERNAME", + "C8Y_USER", + } + for _, k := range usernameAliases { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + session.Username = v + break + } + } + + if session.SessionUri == "" { + session.SessionUri = "env://host" + } + + return session, nil +} + +func (n *CmdLogin) FromExternalProvider(args []string) (*c8ysession.CumulocitySession, error) { + cfg, err := n.factory.Config() + if err != nil { + return nil, err + } + log, err := n.factory.Logger() + if err != nil { + return nil, err + } + + providerCmd := strings.TrimSpace(n.Exec) + if providerCmd == "" { + providerCmd = strings.TrimSpace(cfg.GetString("settings.session.providerCmd")) + } + if providerCmd == "" { + return nil, fmt.Errorf("provider is not set") + } + + cmdArgs, err := shellquote.Split(providerCmd) + if err != nil { + return nil, err + } + if len(cmdArgs) == 0 { + return nil, fmt.Errorf("executable is empty") + } + cmdExec := cmdArgs[0] + cmd := exec.Command(cmdExec, slices.Concat(cmdArgs[1:], args)...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + + log.Infof("Executing session provider: %s", providerCmd) + output, err := cmd.Output() + if err != nil { + return nil, err + } + + if n.Format == "" { + // Try to detect the format + if jsonUtilities.IsJSONObject(output) { + n.Format = "json" + log.Infof("Detected input format: %s", n.Format) + } else { + n.Format = "dotenv" + log.Infof("Guessing input format: %s", n.Format) + } + } + + log.Infof("Parsing session provider output: %s", output) + return n.FromReader(bytes.NewReader(output), n.Format) +} + +func (n *CmdLogin) FromViper(v *viper.Viper) (*c8ysession.CumulocitySession, error) { + + getValue := func(keys ...string) string { + for _, k := range keys { + if value := v.GetString(k); value != "" { + return value + } + // use fallback value + if value := v.GetString(config.EnvSettingsPrefix + "_" + k); value != "" { + return value + } + } + return "" + } + + session := &c8ysession.CumulocitySession{ + SessionUri: getValue("sessionUri"), + Path: getValue("path"), + Username: getValue("username"), + Password: getValue("password"), + Tenant: getValue("tenant"), + Token: getValue("token"), + TOTP: getValue("totp"), + } + session.SetHost(getValue("host")) + return session, nil +} + +func (n *CmdLogin) FromFile(file string, format string) (*c8ysession.CumulocitySession, error) { + v := viper.New() + if format != "" { + v.SetConfigType(format) + } + v.SetConfigFile(file) + err := v.ReadInConfig() + if err != nil { + return nil, err + } + + return n.FromViper(v) +} + +func (n *CmdLogin) FromReader(r io.Reader, format string) (*c8ysession.CumulocitySession, error) { + v := viper.New() + if format != "" { + v.SetConfigType(format) + } + err := v.ReadConfig(r) + if err != nil { + return nil, fmt.Errorf("invalid session format. expected_format=%s. error=%w", format, err) + } + + return n.FromViper(v) +} + +func (n *CmdLogin) RunE(cmd *cobra.Command, args []string) error { + cfg, err := n.factory.Config() + if err != nil { + return err + } + log, err := n.factory.Logger() + if err != nil { + return err + } + + if n.Provider == "" { + n.Provider = cfg.GetString("settings.session.provider") + if n.Provider == "" { + n.Provider = ProviderTypeAuto + } + } + + if strings.EqualFold(n.Provider, ProviderTypeAuto) { + // + // Try guessing a sensible default + // + if n.File != "" { + n.Provider = ProviderTypeFile + } else if n.Stdin { + n.Provider = ProviderTypeStdin + } else if n.Env { + n.Provider = ProviderTypeEnv + } else if n.Exec != "" { + n.Provider = ProviderTypeExternal + } else if os.Getenv("CI") != "" { + // CI environment and generally env variables are used here + n.Provider = ProviderTypeEnv + } else if n.factory.IOStreams.HasStdin() { + n.Provider = ProviderTypeStdin + } + } + + var session *c8ysession.CumulocitySession + + switch strings.ToLower(n.Provider) { + case ProviderTypeExternal: + session, err = n.FromExternalProvider(args) + case ProviderTypeEnv: + session, err = n.FromEnv() + case ProviderTypeStdin: + if !n.factory.IOStreams.HasStdin() { + err = fmt.Errorf("no stdin detected") + } else { + session, err = n.FromStdin(n.Format, args) + } + case ProviderTypeFile: + session, err = n.FromFile(n.File, n.Format) + default: + return fmt.Errorf("unknown provider") + } + + if err != nil { + return err + } + + // Fail early if the domain is not set + if session.GetDomain() == "" { + return cmderrors.NewUserError("invalid session. host is empty") + } + + client := c8y.NewClient(nil, session.Host, session.Tenant, session.Username, session.Password, true) + client.SetToken(session.Token) + + c8ysession.ClearProcessEnvironment() + + handler := c8ylogin.NewLoginHandler(client, cmd.ErrOrStderr(), func() {}) + handler.Interactive = true + handler.LoginType = strings.ToUpper(cfg.GetLoginType()) + if n.LoginType != "" { + handler.LoginType = strings.ToUpper(n.LoginType) + } + + log.Infof("User preference for login type: %s", handler.LoginType) + handler.TFACode = session.TOTP + handler.SetLogger(log) + err = handler.Run() + if err != nil { + return err + } + + session.Token = client.Token + if client.TenantName != "" { + session.Tenant = client.TenantName + } + session.Version = client.Version + session.Username = handler.C8Yclient.Username + session.Host = handler.C8Yclient.BaseURL.Host + session.Path = cfg.GetSessionFile() + + // Write session details to stderr (for humans) + if !n.NoBanner { + c8ysession.PrintSessionInfo(n.SubCommand.GetCommand().ErrOrStderr(), client, cfg, *session) + } + + outputFormat := n.OutputFormat + if outputFormat == "" { + if n.Shell == "" && !n.factory.IOStreams.IsStdoutTTY() { + n.Shell = "auto" + } + if strings.EqualFold(n.Shell, "auto") { + n.Shell = shell.DetectShell("bash") + } + outputFormat = n.Shell + } + + // Write session details to stdout (for machines) + return c8ysession.WriteOutput(n.GetCommand().OutOrStdout(), client, cfg, session, outputFormat) +} diff --git a/pkg/cmd/sessions/sessions.manual.go b/pkg/cmd/sessions/sessions.manual.go index a53a581e1..370c0bffc 100644 --- a/pkg/cmd/sessions/sessions.manual.go +++ b/pkg/cmd/sessions/sessions.manual.go @@ -8,6 +8,7 @@ import ( encryptCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/sessions/encrypttext" getCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/sessions/get" listCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/sessions/list" + loginCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/sessions/login" setCmd "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/sessions/set" "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/subcommand" "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmdutil" @@ -36,6 +37,7 @@ func NewSubCommand(f *cmdutil.Factory) *sessionsCmd { cmd.AddCommand(cmdutil.DisableAuthCheck(listCmd.NewCmdList(f).GetCommand())) cmd.AddCommand(cmdutil.DisableAuthCheck(setCmd.NewCmdSet(f).GetCommand())) cmd.AddCommand(cmdutil.DisableAuthCheck(cloneCmd.NewCmdCloneSession(f).GetCommand())) + cmd.AddCommand(cmdutil.DisableAuthCheck(loginCmd.NewCmdLogin(f).GetCommand())) ccmd.SubCommand = subcommand.NewSubCommand(cmd) diff --git a/pkg/cmd/sessions/set/set.manual.go b/pkg/cmd/sessions/set/set.manual.go index 91c8d0539..338d7f90d 100644 --- a/pkg/cmd/sessions/set/set.manual.go +++ b/pkg/cmd/sessions/set/set.manual.go @@ -210,23 +210,39 @@ func (n *CmdSet) RunE(cmd *cobra.Command, args []string) error { n.onSave(handler.C8Yclient) } - c8ysession.PrintSessionInfo(n.SubCommand.GetCommand().ErrOrStderr(), client, cfg, c8ysession.CumulocitySession{ - Path: cfg.GetSessionFile(), - Host: handler.C8Yclient.BaseURL.Host, - Tenant: cfg.GetTenant(), - Version: cfg.GetCumulocityVersion(), - Username: handler.C8Yclient.Username, - }) + session := &c8ysession.CumulocitySession{ + Path: cfg.GetSessionFile(), + SessionUri: "file://" + cfg.GetSessionFile(), + Host: handler.C8Yclient.BaseURL.Host, + Password: handler.C8Yclient.Password, + Token: handler.C8Yclient.Token, + Tenant: cfg.GetTenant(), + Version: cfg.GetCumulocityVersion(), + Username: handler.C8Yclient.Username, + } + + outputFormat := cfg.GetOutputFormatWithDefault(config.OutputUnknown).String() - if n.Shell != "" { + // Write session details to stderr (for humans) + if outputFormat != config.OutputJSON.String() { + c8ysession.PrintSessionInfo(n.SubCommand.GetCommand().ErrOrStderr(), client, cfg, *session) + } + + if outputFormat == config.OutputUnknown.String() { + if n.Shell == "" && !n.factory.IOStreams.IsStdoutTTY() { + n.Shell = "auto" + } if strings.EqualFold(n.Shell, "auto") { n.Shell = shell.DetectShell("bash") } - shell := utilities.ShellBash - utilities.ShowClientEnvironmentVariables(cfg, handler.C8Yclient, shell.FromString(n.Shell)) + outputFormat = n.Shell + } else if outputFormat != config.OutputJSON.String() { + // Don't output for any other format + return nil } - return nil + // Write session details to stdout (for machines) + return c8ysession.WriteOutput(n.GetCommand().OutOrStdout(), client, cfg, session, outputFormat) } func hasChanged(client *c8y.Client, cfg *config.Config) bool { diff --git a/pkg/cmd/settings/update/update.manual.go b/pkg/cmd/settings/update/update.manual.go index e08393150..980974da3 100644 --- a/pkg/cmd/settings/update/update.manual.go +++ b/pkg/cmd/settings/update/update.manual.go @@ -584,7 +584,7 @@ func (n *UpdateSettingsCmd) RunE(cmd *cobra.Command, args []string) error { envKey := strings.ToUpper(strings.ReplaceAll(config.EnvSettingsPrefix+"_"+key, ".", "_")) cfg[envKey] = v.Get(key) } - utilities.ShowEnvironmentVariables(cfg, shell.FromString(n.shell)) + utilities.WriteShellVariables(cmd.OutOrStdout(), cfg, shell.FromString(n.shell)) } return nil diff --git a/pkg/config/cliConfiguration.go b/pkg/config/cliConfiguration.go index 1007aab2a..03f47b19b 100644 --- a/pkg/config/cliConfiguration.go +++ b/pkg/config/cliConfiguration.go @@ -419,6 +419,11 @@ func NewConfig(v *viper.Viper) *Config { return c } +// Set non-persisted values +func (c *Config) Set(key string, value any) { + c.viper.Set(key, value) +} + func (c *Config) RegisterTemplateResolver(resolver flags.Resolver) { c.templateResolver = resolver } @@ -1443,6 +1448,15 @@ func (c *Config) GetOutputFormat() OutputFormat { return outputFormat } +// GetOutputFormat Get output format type, i.e. json, csv, table etc. +func (c *Config) GetOutputFormatWithDefault(fallback OutputFormat) OutputFormat { + if c.RawOutput() { + return OutputJSON + } + format := c.viper.GetString(SettingsOutputFormat) + return fallback.FromString(format) +} + // IsCSVOutput check if csv output is enabled func (c *Config) IsCSVOutput() bool { format := c.GetOutputFormat() @@ -1469,6 +1483,11 @@ func (c *Config) EncryptionEnabled() bool { return c.viper.GetBool(SettingsEncryptionEnabled) } +// Enable/Disable encryption +func (c *Config) SetEncryptionEnabled(v bool) { + c.viper.Set(SettingsEncryptionEnabled, v) +} + // GetActivityLogPath path where the activity log will be stored func (c *Config) GetActivityLogPath() string { return c.ExpandHomePath(c.viper.GetString(SettingsActivityLogPath)) diff --git a/pkg/config/outputformat.go b/pkg/config/outputformat.go index af324ed7b..276254354 100644 --- a/pkg/config/outputformat.go +++ b/pkg/config/outputformat.go @@ -25,6 +25,9 @@ const ( // OutputServerResponse unparsed output as received from the server OutputServerResponse + + // OutputUnknown unknown/unsupported output format + OutputUnknown ) func (f OutputFormat) String() string { @@ -36,6 +39,7 @@ func (f OutputFormat) String() string { OutputCompletion: "completion", OutputCSVWithHeader: "csvheader", OutputServerResponse: "serverresponse", + OutputUnknown: "unknown", } if v, ok := values[f]; ok { @@ -53,6 +57,7 @@ func (f OutputFormat) FromString(name string) OutputFormat { "completion": OutputCompletion, "csvheader": OutputCSVWithHeader, "serverresponse": OutputServerResponse, + "unknown": OutputUnknown, } if v, ok := values[strings.ToLower(name)]; ok { diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 46b275126..f5189e0a1 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -139,6 +139,11 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) HasStdin() bool { + stat, _ := os.Stdin.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + func (s *IOStreams) CanPrompt() bool { if s.neverPrompt { return false diff --git a/pkg/passwordmanager/bitwarden/main.go b/pkg/passwordmanager/bitwarden/main.go index 0ab297b29..dded5517c 100644 --- a/pkg/passwordmanager/bitwarden/main.go +++ b/pkg/passwordmanager/bitwarden/main.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/cli/safeexec" "github.com/manifoldco/promptui" "github.com/pquerna/otp/totp" ) @@ -21,6 +22,16 @@ type BWItem struct { Fields []BWField `json:"fields"` } +func (bwi *BWItem) HasTenantField() bool { + for _, field := range bwi.Fields { + name := strings.ToLower(field.Name) + if strings.Contains(name, "tenant") && strings.TrimSpace(field.Value) != "" { + return true + } + } + return false +} + // BWLogin bitwarden login credentials type BWLogin struct { Username string `json:"username"` @@ -38,11 +49,32 @@ type BWField struct { Type int32 `json:"type"` } +func (b *BWLogin) MatchesUri(search string) bool { + for _, uri := range b.Uris { + if strings.Contains(strings.ToLower(uri.URI), search) { + return true + } + } + return false +} + // BWUri bitwarden URI associated with the login credentials type BWUri struct { URI string `json:"uri"` } +func checkBitwarden() error { + if os.Getenv("BW_SESSION") == "" { + return fmt.Errorf("bitwarden env variable not set. Expected BW_SESSION to be defined and not empty") + } + + if _, err := safeexec.LookPath("bw"); err != nil { + return fmt.Errorf("could not find 'bw' (bitwarden-cli). Check if it is installed on your machine") + } + + return nil +} + func getBWItems(name ...string) []BWItem { bw := exec.Command("bw", "list", "items", "--session", os.Getenv("BW_SESSION")) @@ -62,7 +94,7 @@ func getBWItems(name ...string) []BWItem { } for _, pattern := range name { - if strings.Contains(item.Name, pattern) { + if strings.Contains(item.Name, pattern) || item.HasTenantField() { if len(item.Fields) > 0 { for _, field := range item.Fields { @@ -93,12 +125,16 @@ func getBWItems(name ...string) []BWItem { func main() { - bwItems := getBWItems("c8y", "cumulocity") + if err := checkBitwarden(); err != nil { + fmt.Printf(err.Error()) + return + } - itemTemplate := `{{ .Name | cyan }} {{ if .Login.Uris }} ({{ (index .Login.Uris 0).Uri | red }}) {{end}} ({{ .Login.Tenant | cyan }}/{{ .Login.Username | cyan }})` + bwItems := getBWItems("c8y", "cumulocity") + itemTemplate := `{{ .Name | cyan }} {{ if .Login.Uris }} ({{ (index .Login.Uris 0).URI | red }}){{end}} ({{ .Login.Tenant | cyan }}/{{ .Login.Username | cyan }})` templates := &promptui.SelectTemplates{ - Label: "{{ . }}?", + Label: "{{ .Name }}?", Active: "\U00002192 " + itemTemplate, Inactive: " " + itemTemplate, // Selected: "{{ .ID }}", @@ -107,8 +143,8 @@ func main() { --------- Session ---------- {{ "Name:" | faint }} {{ .Name }} {{ "ID:" | faint }} {{ .ID }} -{{ "Uri:" | faint }} {{ (index .Login.Uris 0).Uri }} {{ "Tenant:" | faint }} {{ .Login.Tenant }} +{{ "Uri:" | faint }} {{ (index .Login.Uris 0).URI }} {{ "Username:" | faint }} {{ .Login.Username }} `, } @@ -118,7 +154,7 @@ func main() { name := strings.Replace(strings.ToLower(item.Name), " ", "", -1) input = strings.Replace(strings.ToLower(input), " ", "", -1) - return strings.Contains(name, input) + return strings.Contains(name, input) || item.Login.MatchesUri(input) } prompt := promptui.Select{ diff --git a/pkg/utilities/utilities.go b/pkg/utilities/utilities.go index d06a4ce65..b0c3d1866 100644 --- a/pkg/utilities/utilities.go +++ b/pkg/utilities/utilities.go @@ -3,6 +3,7 @@ package utilities import ( "bytes" "fmt" + "io" "net/http" "os" "runtime" @@ -25,7 +26,7 @@ func GetFileContentType(out *os.File) (string, error) { return "", err } - // Use the net/http package's handy DectectContentType function. Always returns a valid + // Use the net/http package's handy DetectContentType function. Always returns a valid // content-type by returning "application/octet-stream" if no others seemed to match. contentType := http.DetectContentType(buffer) @@ -48,6 +49,18 @@ func (t ShellType) FromString(name string) ShellType { return t } +func (t ShellType) Parse(name string) (ShellType, bool) { + values := map[string]ShellType{ + "powershell": ShellPowerShell, + "bash": ShellBash, + "zsh": ShellZSH, + "fish": ShellFish, + } + + v, ok := values[strings.ToLower(name)] + return v, ok +} + const ( // ShellBash bash ShellBash ShellType = iota @@ -62,12 +75,7 @@ const ( ShellFish ) -func ShowClientEnvironmentVariables(cfg *config.Config, c8yclient *c8y.Client, shell ShellType) { - output := cfg.GetEnvironmentVariables(c8yclient, cfg.AlwaysIncludePassword()) - ShowEnvironmentVariables(output, shell) -} - -func ShowEnvironmentVariables(cfg map[string]interface{}, shell ShellType) { +func WriteShellVariables(w io.Writer, cfg map[string]interface{}, shell ShellType) { // sort output variables variables := []string{} @@ -81,21 +89,21 @@ func ShowEnvironmentVariables(cfg map[string]interface{}, shell ShellType) { switch shell { case ShellPowerShell: if value == "" { - fmt.Printf("$env:%s = $null\n", name) + fmt.Fprintf(w, "$env:%s = $null\n", name) } else { - fmt.Printf("$env:%s = '%v'\n", name, value) + fmt.Fprintf(w, "$env:%s = '%v'\n", name, value) } case ShellFish: if value == "" { - fmt.Printf("set -e %s\n", name) + fmt.Fprintf(w, "set -e %s\n", name) } else { - fmt.Printf("set -gx %s '%v'\n", name, value) + fmt.Fprintf(w, "set -gx %s '%v'\n", name, value) } default: if value == "" { - fmt.Printf("unset %s\n", name) + fmt.Fprintf(w, "unset %s\n", name) } else { - fmt.Printf("export %s='%v'\n", name, value) + fmt.Fprintf(w, "export %s='%v'\n", name, value) } } } @@ -103,26 +111,7 @@ func ShowEnvironmentVariables(cfg map[string]interface{}, shell ShellType) { // ClearEnvironmentVariables clears all the session related environment variables by passing // a shell snippet to execute via source or eval. -func ClearEnvironmentVariables(shell ShellType) { - variables := []string{ - "C8Y_HOST", - "C8Y_URL", - "C8Y_BASEURL", - "C8Y_DOMAIN", - "C8Y_TENANT", - "C8Y_USER", - "C8Y_USERNAME", - "C8Y_PASSWORD", - "C8Y_TOKEN", - "C8Y_VERSION", - "C8Y_SESSION", - "C8Y_HEADER", - "C8Y_HEADER_AUTHORIZATION", - "C8Y_SETTINGS_MODE_ENABLECREATE", - "C8Y_SETTINGS_MODE_ENABLEUPDATE", - "C8Y_SETTINGS_MODE_ENABLEDELETE", - } - +func ClearEnvironmentVariables(variables []string, shell ShellType) { sort.Strings(variables) for _, name := range variables { switch shell { @@ -136,6 +125,12 @@ func ClearEnvironmentVariables(shell ShellType) { } } +func ClearProcessEnvironment(variables []string) { + for _, key := range variables { + os.Unsetenv(key) + } +} + func CheckEncryption(IO *iostreams.IOStreams, cfg *config.Config, client *c8y.Client) error { encryptionEnabled := cfg.IsEncryptionEnabled() decryptSession := false diff --git a/tests/manual/sessions/login/session_login.yaml b/tests/manual/sessions/login/session_login.yaml new file mode 100644 index 000000000..e2141b864 --- /dev/null +++ b/tests/manual/sessions/login/session_login.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/reubenmiller/commander/feat/handle-nested-files/schema.json + +tests: + It reads a session from a command: + command: | + env -i PATH="$PATH" C8Y_HOST="$C8Y_HOST" C8Y_USERNAME="$C8Y_USERNAME" C8Y_PASSWORD="$C8Y_PASSWORD" \ + c8y sessions login --from-cmd "c8y sessions login --from-env --output-format json --no-banner" --output-format json + exit-code: 0 + stdout: + json: + host: r/^.+$ + tenant: r/^.+$ + version: r/^.+$ + username: r/^.+$ + token: r/^.+$ + + It reads a session from environment variables: + command: | + env -i PATH="$PATH" C8Y_HOST="$C8Y_HOST" C8Y_USERNAME="$C8Y_USERNAME" C8Y_PASSWORD="$C8Y_PASSWORD" \ + c8y sessions login --from-env --output-format json + exit-code: 0 + stdout: + json: + host: r/^.+$ + tenant: r/^.+$ + version: r/^.+$ + username: r/^.+$ + token: r/^.+$