diff --git a/.gitignore b/.gitignore index 65cd0b4..944a4fe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ /.claude /.claude/settings.local.json /.claude/commands +/tui.json diff --git a/go.mod b/go.mod index 642d064..50db329 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,45 @@ module github.com/pixie-sh/pixie-cli go 1.25.1 require ( + github.com/jackc/pgx/v5 v5.9.1 + github.com/pixie-sh/database-helpers-go v0.2.20 github.com/pixie-sh/errors-go v0.3.7 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.48.0 ) require ( + github.com/chzyer/readline v1.5.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-gormigrate/gormigrate/v2 v2.1.2 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pixie-sh/logger-go v0.4.4 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.29.0 // indirect + gorm.io/driver/mysql v1.5.7 // indirect + gorm.io/driver/postgres v1.5.7 // indirect + gorm.io/gorm v1.30.0 // indirect + gorm.io/plugin/dbresolver v1.6.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 68ceb05..159476d 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,129 @@ +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/go-gormigrate/gormigrate/v2 v2.1.2 h1:F/d1hpHbRAvKezziV2CC5KUE82cVe9zTgHSBoOOZ4CY= +github.com/go-gormigrate/gormigrate/v2 v2.1.2/go.mod h1:9nHVX6z3FCMCQPA7PThGcA55t22yKQfK/Dnsf5i7hUo= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pixie-sh/database-helpers-go v0.2.20 h1:uBfcw6NoeO21ql4timNNE5SHIhkiQLV9+ecM+wL9ngQ= +github.com/pixie-sh/database-helpers-go v0.2.20/go.mod h1:m8A3PH5XNhaORMA4vT9CqqKHBi7esx19go71w6jnZ7k= github.com/pixie-sh/errors-go v0.3.7 h1:RVHmBwoQmEkgboygEln2g9zvVSIPyjPOzkywjTFoIZM= github.com/pixie-sh/errors-go v0.3.7/go.mod h1:rDwoMPeRVE7tY2XnM+eNJrV9niHuk0qcOfDnAy1IRGg= github.com/pixie-sh/logger-go v0.4.4 h1:3br4QUVsIWLG02Hc/QwruoRWvWY456D4+RiMuJus8lE= github.com/pixie-sh/logger-go v0.4.4/go.mod h1:BeQAP6KwcjybrnjjpyaDrc9bxvstTo4ZFALqul44nl0= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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/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= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/plugin/dbresolver v1.6.0 h1:XvKDeOtTn1EIX6s4SrKpEH82q0gXVemhYjbYZFGFVcw= +gorm.io/plugin/dbresolver v1.6.0/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/cli/pixie/app/run.go b/internal/cli/pixie/app/run.go index b6169b7..b9b3bf5 100644 --- a/internal/cli/pixie/app/run.go +++ b/internal/cli/pixie/app/run.go @@ -6,12 +6,21 @@ import ( "github.com/spf13/cobra" + "github.com/pixie-sh/pixie-cli/internal/cli/pixie/db_shell_cmd" "github.com/pixie-sh/pixie-cli/internal/cli/pixie/generate_cmd" "github.com/pixie-sh/pixie-cli/internal/cli/pixie/init_cmd" "github.com/pixie-sh/pixie-cli/internal/version" ) func Run() { + rootCmd := NewRootCmd() + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func NewRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "pixie", Short: "Pixie CLI - Multi-Stack Project Generator", @@ -24,6 +33,7 @@ func Run() { rootCmd.PersistentFlags().String("env", "", "Path to environment file") rootCmd.AddCommand(init_cmd.InitCmd()) rootCmd.AddCommand(generate_cmd.GenerateCmd()) + rootCmd.AddCommand(db_shell_cmd.Cmd()) rootCmd.AddCommand(&cobra.Command{ Use: "version", Short: "Print version information", @@ -32,7 +42,5 @@ func Run() { }, }) - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } + return rootCmd } diff --git a/internal/cli/pixie/app/run_test.go b/internal/cli/pixie/app/run_test.go new file mode 100644 index 0000000..5c62257 --- /dev/null +++ b/internal/cli/pixie/app/run_test.go @@ -0,0 +1,19 @@ +package app + +import "testing" + +func TestNewRootCmdIncludesDBShell(t *testing.T) { + root := NewRootCmd() + + found := false + for _, command := range root.Commands() { + if command.Name() == "db-shell" { + found = true + break + } + } + + if !found { + t.Fatal("db-shell command not registered on root command") + } +} diff --git a/internal/cli/pixie/db_shell_cmd/command.go b/internal/cli/pixie/db_shell_cmd/command.go new file mode 100644 index 0000000..3de2bfe --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/command.go @@ -0,0 +1,76 @@ +package db_shell_cmd + +import ( + "os/signal" + + "github.com/spf13/cobra" +) + +// Cmd returns the db-shell command. +func Cmd() *cobra.Command { + var opts Options + + cmd := &cobra.Command{ + Use: "db-shell", + Short: "Open an interactive SQL shell through Pixie runtime abstractions", + Long: `Open an interactive SQL shell through Pixie runtime abstractions. + +Configuration precedence: + 1. Command flags + 2. Explicit env file values (--env), then process environment variables + 3. Project config in .pixie.yaml or pixie.yaml under the db: section + 4. Built-in defaults + +Runtime paths: + - Helper-backed PostgreSQL is the primary runtime path + - SQLite remains available for local fallback and tests + +Supported built-ins: + .help Show available shell commands + .exit Close the session + .quit Close the session + +Examples: + pixie db-shell --driver sqlite --dsn "file:pixie-shell.db" + pixie --env .env db-shell --name app_db --user postgres + pixie --config .pixie.yaml db-shell --driver postgres`, + RunE: func(cmd *cobra.Command, args []string) error { + configPath, _ := cmd.InheritedFlags().GetString("config") + envPath, _ := cmd.InheritedFlags().GetString("env") + + resolvedConfig, err := ResolveConfig(opts, resolveConfigPath(configPath), envPath, nil) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(cmd.Context(), interruptSignals...) + defer stop() + + executor, err := OpenExecutor(ctx, resolvedConfig) + if err != nil { + return err + } + + shell := Shell{ + Executor: executor, + In: cmd.InOrStdin(), + Out: cmd.OutOrStdout(), + ErrOut: cmd.ErrOrStderr(), + Prompt: "pixie-sql> ", + } + + return shell.Run(ctx) + }, + } + + cmd.Flags().StringVar(&opts.Driver, "driver", defaultPostgresDriver, "Database driver (postgres primary path; sqlite fallback/test-only)") + cmd.Flags().StringVar(&opts.DSN, "dsn", "", "Raw DSN/connection string (overrides host/user/name fields)") + cmd.Flags().StringVar(&opts.Host, "host", "", "Database host for helper-backed PostgreSQL connections") + cmd.Flags().IntVar(&opts.Port, "port", 0, "Database port for helper-backed PostgreSQL connections") + cmd.Flags().StringVar(&opts.Name, "name", "", "Database name for helper-backed PostgreSQL connections") + cmd.Flags().StringVar(&opts.User, "user", "", "Database user for helper-backed PostgreSQL connections") + cmd.Flags().StringVar(&opts.Password, "password", "", "Database password for helper-backed PostgreSQL connections") + cmd.Flags().StringVar(&opts.SSLMode, "sslmode", "", "SSL mode for helper-backed PostgreSQL connections") + + return cmd +} diff --git a/internal/cli/pixie/db_shell_cmd/config.go b/internal/cli/pixie/db_shell_cmd/config.go new file mode 100644 index 0000000..5cbea22 --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/config.go @@ -0,0 +1,352 @@ +package db_shell_cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/pixie-sh/errors-go" + "gopkg.in/yaml.v3" +) + +const ( + defaultPostgresDriver = "postgres" + defaultSQLiteDriver = "sqlite" + defaultSQLiteDSN = "file:pixie-shell.db" +) + +type Options struct { + Driver string + DSN string + Host string + Port int + Name string + User string + Password string + SSLMode string +} + +type DBConfig struct { + Driver string `yaml:"driver"` + DSN string `yaml:"dsn"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Name string `yaml:"name"` + User string `yaml:"user"` + Password string `yaml:"password"` + SSLMode string `yaml:"sslmode"` +} + +type runtimeConfigFile struct { + DB DBConfig `yaml:"db"` +} + +type EnvironmentLookup func(string) string + +type ResolvedConfig struct { + Driver string + DSN string + Host string + Port int + Name string + User string + Password string + SSLMode string +} + +func defaultConfig() ResolvedConfig { + return ResolvedConfig{ + Driver: defaultPostgresDriver, + Host: "localhost", + Port: 5432, + Name: "postgres", + User: "postgres", + SSLMode: "disable", + } +} + +func ResolveConfig(opts Options, configPath, envPath string, envLookup EnvironmentLookup) (ResolvedConfig, error) { + if envLookup == nil { + envLookup = os.Getenv + } + + cfg := defaultConfig() + + fileCfg, err := loadRuntimeConfig(configPath) + if err != nil { + return ResolvedConfig{}, err + } + applyDBConfig(&cfg, fileCfg) + + envFileValues, err := loadEnvFile(envPath) + if err != nil { + return ResolvedConfig{}, err + } + applyEnvValues(&cfg, func(key string) string { + return envFileValues[key] + }) + applyEnvValues(&cfg, envLookup) + applyOptions(&cfg, opts) + + if err := finalizeConfig(&cfg); err != nil { + return ResolvedConfig{}, err + } + + return cfg, nil +} + +func (c ResolvedConfig) SQLDriverName() string { + switch c.Driver { + case defaultSQLiteDriver: + return defaultSQLiteDriver + default: + return "pgx" + } +} + +func (c ResolvedConfig) IsSQLite() bool { + return c.Driver == defaultSQLiteDriver +} + +func (c ResolvedConfig) SafeSummary() string { + if c.Driver == defaultSQLiteDriver { + return fmt.Sprintf("sqlite (%s)", c.DSN) + } + + return fmt.Sprintf("%s %s:%d/%s as %s (sslmode=%s)", c.Driver, c.Host, c.Port, c.Name, c.User, c.SSLMode) +} + +func loadRuntimeConfig(configPath string) (DBConfig, error) { + paths := []string{} + if configPath != "" { + paths = append(paths, configPath) + } else { + paths = append(paths, ".pixie.yaml", "pixie.yaml") + } + + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return DBConfig{}, errors.Wrap(err, "failed to read config file: %s", path) + } + + var cfg runtimeConfigFile + if err := yaml.Unmarshal(content, &cfg); err != nil { + return DBConfig{}, errors.Wrap(err, "failed to parse config file: %s", path) + } + + return cfg.DB, nil + } + + return DBConfig{}, nil +} + +func loadEnvFile(envPath string) (map[string]string, error) { + if envPath == "" { + return map[string]string{}, nil + } + + content, err := os.ReadFile(envPath) + if err != nil { + return nil, errors.Wrap(err, "failed to read env file: %s", envPath) + } + + values := make(map[string]string) + for index, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return nil, errors.New("invalid env file line %d in %s", index+1, envPath) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"'`) + values[key] = value + } + + return values, nil +} + +func applyDBConfig(target *ResolvedConfig, source DBConfig) { + if source.Driver != "" { + target.Driver = normalizeDriver(source.Driver) + } + if source.DSN != "" { + target.DSN = source.DSN + } + if source.Host != "" { + target.Host = source.Host + } + if source.Port != 0 { + target.Port = source.Port + } + if source.Name != "" { + target.Name = source.Name + } + if source.User != "" { + target.User = source.User + } + if source.Password != "" { + target.Password = source.Password + } + if source.SSLMode != "" { + target.SSLMode = source.SSLMode + } +} + +func applyOptions(target *ResolvedConfig, opts Options) { + if opts.Driver != "" { + target.Driver = normalizeDriver(opts.Driver) + } + if opts.DSN != "" { + target.DSN = opts.DSN + } + if opts.Host != "" { + target.Host = opts.Host + } + if opts.Port != 0 { + target.Port = opts.Port + } + if opts.Name != "" { + target.Name = opts.Name + } + if opts.User != "" { + target.User = opts.User + } + if opts.Password != "" { + target.Password = opts.Password + } + if opts.SSLMode != "" { + target.SSLMode = opts.SSLMode + } +} + +func applyEnvValues(target *ResolvedConfig, lookup EnvironmentLookup) { + if lookup == nil { + return + } + + if value := lookup("PIXIE_DB_DRIVER"); value != "" { + target.Driver = normalizeDriver(value) + } else if value := lookup("DB_DRIVER"); value != "" { + target.Driver = normalizeDriver(value) + } + if value := lookup("PIXIE_DB_DSN"); value != "" { + target.DSN = value + } else if value := lookup("DB_DSN"); value != "" { + target.DSN = value + } else if value := lookup("DATABASE_URL"); value != "" { + target.DSN = value + } + if value := lookup("PIXIE_DB_HOST"); value != "" { + target.Host = value + } else if value := lookup("DB_HOST"); value != "" { + target.Host = value + } else if value := lookup("PGHOST"); value != "" { + target.Host = value + } + if value := lookup("PIXIE_DB_PORT"); value != "" { + if port, err := strconv.Atoi(value); err == nil { + target.Port = port + } + } else if value := lookup("DB_PORT"); value != "" { + if port, err := strconv.Atoi(value); err == nil { + target.Port = port + } + } else if value := lookup("PGPORT"); value != "" { + if port, err := strconv.Atoi(value); err == nil { + target.Port = port + } + } + if value := lookup("PIXIE_DB_NAME"); value != "" { + target.Name = value + } else if value := lookup("DB_NAME"); value != "" { + target.Name = value + } else if value := lookup("PGDATABASE"); value != "" { + target.Name = value + } + if value := lookup("PIXIE_DB_USER"); value != "" { + target.User = value + } else if value := lookup("DB_USERNAME"); value != "" { + target.User = value + } else if value := lookup("DB_USER"); value != "" { + target.User = value + } else if value := lookup("PGUSER"); value != "" { + target.User = value + } + if value := lookup("PIXIE_DB_PASSWORD"); value != "" { + target.Password = value + } else if value := lookup("DB_PASSWORD"); value != "" { + target.Password = value + } else if value := lookup("PGPASSWORD"); value != "" { + target.Password = value + } + if value := lookup("PIXIE_DB_SSLMODE"); value != "" { + target.SSLMode = value + } else if value := lookup("DB_SSL_MODE"); value != "" { + target.SSLMode = value + } else if value := lookup("DB_SSLMODE"); value != "" { + target.SSLMode = value + } else if value := lookup("PGSSLMODE"); value != "" { + target.SSLMode = value + } +} + +func finalizeConfig(target *ResolvedConfig) error { + target.Driver = normalizeDriver(target.Driver) + if target.Driver != defaultPostgresDriver && target.Driver != defaultSQLiteDriver { + return errors.New("unsupported driver: %s (db-shell supports helper-backed postgres and sqlite fallback/test-only)", target.Driver) + } + + if target.Driver == defaultSQLiteDriver { + if target.DSN == "" { + target.DSN = defaultSQLiteDSN + } + return nil + } + + if target.DSN == "" { + if target.Name == "" { + return errors.New("database name is required when dsn is not provided") + } + target.DSN = fmt.Sprintf( + "host=%s port=%d dbname=%s user=%s password=%s sslmode=%s", + target.Host, + target.Port, + target.Name, + target.User, + target.Password, + target.SSLMode, + ) + } + + return nil +} + +func normalizeDriver(driver string) string { + switch strings.ToLower(strings.TrimSpace(driver)) { + case "", "postgres", "postgresql", "pgx": + return defaultPostgresDriver + case "sqlite", "sqlite3": + return defaultSQLiteDriver + default: + return strings.ToLower(strings.TrimSpace(driver)) + } +} + +func resolveConfigPath(configPath string) string { + if configPath == "" { + return "" + } + return filepath.Clean(configPath) +} diff --git a/internal/cli/pixie/db_shell_cmd/config_test.go b/internal/cli/pixie/db_shell_cmd/config_test.go new file mode 100644 index 0000000..f1bd9c8 --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/config_test.go @@ -0,0 +1,142 @@ +package db_shell_cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveConfigPrecedence(t *testing.T) { + tmp := t.TempDir() + configPath := filepath.Join(tmp, "pixie.yaml") + envPath := filepath.Join(tmp, ".env") + + configContent := []byte("db:\n driver: postgres\n host: config-host\n port: 6000\n name: config-db\n user: config-user\n password: config-pass\n sslmode: require\n") + if err := os.WriteFile(configPath, configContent, 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + envContent := []byte("PIXIE_DB_HOST=env-file-host\nPIXIE_DB_PORT=7000\nPIXIE_DB_NAME=env-file-db\nPIXIE_DB_USER=env-file-user\n") + if err := os.WriteFile(envPath, envContent, 0644); err != nil { + t.Fatalf("failed to write env file: %v", err) + } + + envLookup := func(key string) string { + values := map[string]string{ + "PIXIE_DB_PORT": "7100", + "PIXIE_DB_PASSWORD": "env-password", + "PIXIE_DB_SSLMODE": "disable", + } + return values[key] + } + + cfg, err := ResolveConfig(Options{ + Host: "flag-host", + Name: "flag-db", + }, configPath, envPath, envLookup) + if err != nil { + t.Fatalf("ResolveConfig() error = %v", err) + } + + if cfg.Host != "flag-host" { + t.Fatalf("Host = %q, want flag-host", cfg.Host) + } + if cfg.Port != 7100 { + t.Fatalf("Port = %d, want 7100", cfg.Port) + } + if cfg.Name != "flag-db" { + t.Fatalf("Name = %q, want flag-db", cfg.Name) + } + if cfg.User != "env-file-user" { + t.Fatalf("User = %q, want env-file-user", cfg.User) + } + if cfg.Password != "env-password" { + t.Fatalf("Password = %q, want env-password", cfg.Password) + } + if cfg.SSLMode != "disable" { + t.Fatalf("SSLMode = %q, want disable", cfg.SSLMode) + } + if cfg.DSN == "" { + t.Fatal("DSN = empty, want generated DSN") + } +} + +func TestResolveConfigSQLiteDefaults(t *testing.T) { + cfg, err := ResolveConfig(Options{Driver: "sqlite"}, "", "", func(string) string { return "" }) + if err != nil { + t.Fatalf("ResolveConfig() error = %v", err) + } + + if cfg.Driver != "sqlite" { + t.Fatalf("Driver = %q, want sqlite", cfg.Driver) + } + if cfg.DSN != defaultSQLiteDSN { + t.Fatalf("DSN = %q, want %q", cfg.DSN, defaultSQLiteDSN) + } +} + +func TestResolveConfigSupportsBackendSystemEnvNames(t *testing.T) { + tmp := t.TempDir() + envPath := filepath.Join(tmp, "cli_pixie.local.env") + + envContent := []byte("DB_HOST=localhost\nDB_PORT=5432\nDB_NAME=grupoegor\nDB_USERNAME=postgres\nDB_PASSWORD=postgres\nDB_SSL_MODE=disable\n") + if err := os.WriteFile(envPath, envContent, 0644); err != nil { + t.Fatalf("failed to write env file: %v", err) + } + + cfg, err := ResolveConfig(Options{}, "", envPath, func(string) string { return "" }) + if err != nil { + t.Fatalf("ResolveConfig() error = %v", err) + } + + if cfg.Driver != defaultPostgresDriver { + t.Fatalf("Driver = %q, want %q", cfg.Driver, defaultPostgresDriver) + } + if cfg.Host != "localhost" { + t.Fatalf("Host = %q, want localhost", cfg.Host) + } + if cfg.Port != 5432 { + t.Fatalf("Port = %d, want 5432", cfg.Port) + } + if cfg.Name != "grupoegor" { + t.Fatalf("Name = %q, want grupoegor", cfg.Name) + } + if cfg.User != "postgres" { + t.Fatalf("User = %q, want postgres", cfg.User) + } + if cfg.Password != "postgres" { + t.Fatalf("Password = %q, want postgres", cfg.Password) + } + if cfg.SSLMode != "disable" { + t.Fatalf("SSLMode = %q, want disable", cfg.SSLMode) + } + if !strings.Contains(cfg.DSN, "dbname=grupoegor") { + t.Fatalf("DSN = %q, want generated postgres DSN", cfg.DSN) + } +} + +func TestResolvedConfigIsSQLite(t *testing.T) { + if !(ResolvedConfig{Driver: "sqlite"}).IsSQLite() { + t.Fatal("IsSQLite() = false, want true") + } + + if (ResolvedConfig{Driver: "postgres"}).IsSQLite() { + t.Fatal("IsSQLite() = true, want false") + } +} + +func TestResolveConfigRejectsUnsupportedDriver(t *testing.T) { + _, err := ResolveConfig(Options{Driver: "mysql"}, "", "", func(string) string { return "" }) + if err == nil { + t.Fatal("ResolveConfig() error = nil, want unsupported driver error") + } + + message := err.Error() + if !strings.Contains(message, "unsupported driver: mysql") { + t.Fatalf("error = %q, want unsupported driver details", message) + } + if !strings.Contains(message, "helper-backed postgres") { + t.Fatalf("error = %q, want postgres scope guidance", message) + } +} diff --git a/internal/cli/pixie/db_shell_cmd/session.go b/internal/cli/pixie/db_shell_cmd/session.go new file mode 100644 index 0000000..186e3ca --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/session.go @@ -0,0 +1,629 @@ +package db_shell_cmd + +import ( + "bufio" + "context" + "database/sql" + stderrors "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/chzyer/readline" + _ "github.com/jackc/pgx/v5/stdlib" + helperdb "github.com/pixie-sh/database-helpers-go/database" + "github.com/pixie-sh/errors-go" + "golang.org/x/term" + _ "modernc.org/sqlite" +) + +const ( + defaultRenderWidth = 100 + maxTableColumnWidth = 32 + interactiveHistorySz = 500 +) + +type ExecutionResult struct { + Columns []string + Rows [][]string + RowsAffected int64 + IsQuery bool +} + +type Executor interface { + Execute(context.Context, string) (ExecutionResult, error) + Close() error + Summary() string +} + +type sqlExecutor struct { + db *sql.DB + summary string +} + +type helperConnection interface { + Ping() error + Close() error + SQLDB() (*sql.DB, error) +} + +type helperConnectionAdapter struct { + orm *helperdb.Orm +} + +var ( + openSQLiteExecutorFunc = openSQLiteExecutor + openHelperExecutorFunc = openHelperExecutor + openHelperConnection = func(ctx context.Context, cfg *helperdb.Configuration) (helperConnection, error) { + orm, err := helperdb.FactoryInstance.Create(ctx, cfg) + if err != nil { + return nil, err + } + + return helperConnectionAdapter{orm: orm}, nil + } + newReadlineInstance = func(cfg *readline.Config) (interactiveConsole, error) { + return readline.NewEx(cfg) + } + isTerminalFunc = func(file *os.File) bool { + return term.IsTerminal(int(file.Fd())) + } + terminalSizeFunc = func(file *os.File) (int, int, error) { + return term.GetSize(int(file.Fd())) + } + newLineReaderFunc = newLineReader +) + +type Shell struct { + Executor Executor + In io.Reader + Out io.Writer + ErrOut io.Writer + Prompt string +} + +type lineReader interface { + ReadLine(context.Context) lineResult + AddHistory(string) + Close() error +} + +type interactiveConsole interface { + Readline() (string, error) + SaveHistory(string) error + Close() error +} + +type bufferedLineReader struct { + reader *bufio.Reader + output io.Writer + prompt string +} + +type readlineLineReader struct { + console interactiveConsole +} + +func OpenExecutor(ctx context.Context, cfg ResolvedConfig) (Executor, error) { + if cfg.IsSQLite() { + return openSQLiteExecutorFunc(ctx, cfg) + } + + return openHelperExecutorFunc(ctx, cfg) +} + +func openSQLiteExecutor(ctx context.Context, cfg ResolvedConfig) (Executor, error) { + db, err := sql.Open(cfg.SQLDriverName(), cfg.DSN) + if err != nil { + return nil, errors.Wrap(err, "failed to open database connection") + } + + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, errors.Wrap(err, "failed to connect to database") + } + + return &sqlExecutor{db: db, summary: cfg.SafeSummary()}, nil +} + +func openHelperExecutor(ctx context.Context, cfg ResolvedConfig) (Executor, error) { + conn, err := openHelperConnection(ctx, buildHelperConfiguration(cfg)) + if err != nil { + return nil, errors.Wrap(err, "failed to open helper-backed database connection") + } + + if err := conn.Ping(); err != nil { + _ = conn.Close() + return nil, errors.Wrap(err, "failed to connect to database") + } + + rawDB, err := conn.SQLDB() + if err != nil { + _ = conn.Close() + return nil, errors.Wrap(err, "failed to access raw database handle") + } + + return &sqlExecutor{db: rawDB, summary: cfg.SafeSummary()}, nil +} + +func (a helperConnectionAdapter) Ping() error { + return a.orm.Ping() +} + +func (a helperConnectionAdapter) Close() error { + return a.orm.Close() +} + +func (a helperConnectionAdapter) SQLDB() (*sql.DB, error) { + return a.orm.DB.DB() +} + +func buildHelperConfiguration(cfg ResolvedConfig) *helperdb.Configuration { + return &helperdb.Configuration{ + Driver: helperdb.GormDriver, + Values: &helperdb.GormDbConfiguration{ + Driver: helperdb.PsqlDriver, + Dsn: cfg.DSN, + }, + } +} + +func (e *sqlExecutor) Summary() string { + return e.summary +} + +func (e *sqlExecutor) Close() error { + return e.db.Close() +} + +func (e *sqlExecutor) Execute(ctx context.Context, statement string) (ExecutionResult, error) { + if isQueryStatement(statement) { + return e.executeQuery(ctx, statement) + } + + result, err := e.db.ExecContext(ctx, statement) + if err != nil { + return ExecutionResult{}, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + rowsAffected = 0 + } + + return ExecutionResult{RowsAffected: rowsAffected}, nil +} + +func (e *sqlExecutor) executeQuery(ctx context.Context, statement string) (ExecutionResult, error) { + rows, err := e.db.QueryContext(ctx, statement) + if err != nil { + return ExecutionResult{}, err + } + defer rows.Close() + + columns, err := rows.Columns() + if err != nil { + return ExecutionResult{}, err + } + + formattedRows := make([][]string, 0) + for rows.Next() { + values := make([]any, len(columns)) + scans := make([]any, len(columns)) + for index := range values { + scans[index] = &values[index] + } + + if err := rows.Scan(scans...); err != nil { + return ExecutionResult{}, err + } + + formattedRow := make([]string, len(columns)) + for index, value := range values { + formattedRow[index] = stringifyValue(value) + } + formattedRows = append(formattedRows, formattedRow) + } + + if err := rows.Err(); err != nil { + return ExecutionResult{}, err + } + + return ExecutionResult{Columns: columns, Rows: formattedRows, IsQuery: true}, nil +} + +func (s Shell) Run(ctx context.Context) error { + if s.Executor == nil { + return errors.New("executor is required") + } + if s.In == nil { + return errors.New("input reader is required") + } + if s.Out == nil { + return errors.New("output writer is required") + } + if s.ErrOut == nil { + s.ErrOut = s.Out + } + if s.Prompt == "" { + s.Prompt = "pixie-sql> " + } + + defer s.Executor.Close() + + fmt.Fprintf(s.Out, "Connected to %s\n", s.Executor.Summary()) + fmt.Fprintln(s.Out, "Enter SQL statements or .help for shell commands.") + + reader, err := newLineReaderFunc(s.In, s.Out, s.ErrOut, s.Prompt) + if err != nil { + return errors.Wrap(err, "failed to initialize shell input") + } + defer reader.Close() + + for { + if ctx.Err() != nil { + fmt.Fprintln(s.ErrOut, "Interrupted. Closing session.") + return nil + } + + result := reader.ReadLine(ctx) + if result.interrupted { + fmt.Fprintln(s.ErrOut, "Interrupted. Closing session.") + return nil + } + if result.eof { + fmt.Fprintln(s.Out) + fmt.Fprintln(s.Out, "Session closed.") + return nil + } + if result.err != nil { + return errors.Wrap(result.err, "failed to read input") + } + + statement := strings.TrimSpace(result.line) + if statement == "" { + continue + } + + reader.AddHistory(statement) + + handled, shouldExit := handleBuiltin(s.Out, statement) + if handled { + if shouldExit { + fmt.Fprintln(s.Out, "Session closed.") + return nil + } + continue + } + + executionResult, err := s.Executor.Execute(ctx, statement) + if err != nil { + fmt.Fprintf(s.ErrOut, "SQL error: %v\n", err) + continue + } + + writeResult(s.Out, executionResult) + } +} + +type lineResult struct { + line string + err error + eof bool + interrupted bool +} + +func newLineReader(input io.Reader, output io.Writer, errOutput io.Writer, prompt string) (lineReader, error) { + inputFile, outputFile, interactive := terminalFiles(input, output) + if !interactive { + return &bufferedLineReader{ + reader: bufio.NewReader(input), + output: output, + prompt: prompt, + }, nil + } + + console, err := newReadlineInstance(&readline.Config{ + Prompt: prompt, + Stdin: inputFile, + Stdout: outputFile, + Stderr: errOutput, + HistoryLimit: interactiveHistorySz, + DisableAutoSaveHistory: true, + }) + if err != nil { + fmt.Fprintf(errOutput, "Warning: interactive line editing unavailable, falling back to basic input: %v\n", err) + return &bufferedLineReader{ + reader: bufio.NewReader(input), + output: output, + prompt: prompt, + }, nil + } + + return &readlineLineReader{console: console}, nil +} + +func terminalFiles(input io.Reader, output io.Writer) (*os.File, *os.File, bool) { + inputFile, inputOK := input.(*os.File) + outputFile, outputOK := output.(*os.File) + if !inputOK || !outputOK { + return nil, nil, false + } + if !isTerminalFunc(inputFile) || !isTerminalFunc(outputFile) { + return nil, nil, false + } + + return inputFile, outputFile, true + +} + +func (r *bufferedLineReader) ReadLine(ctx context.Context) lineResult { + fmt.Fprint(r.output, r.prompt) + + resultChan := make(chan lineResult, 1) + go func() { + line, err := r.reader.ReadString('\n') + if err != nil { + if stderrors.Is(err, io.EOF) { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + resultChan <- lineResult{line: trimmed} + return + } + resultChan <- lineResult{eof: true} + return + } + resultChan <- lineResult{err: err} + return + } + + resultChan <- lineResult{line: strings.TrimRight(line, "\r\n")} + }() + + select { + case <-ctx.Done(): + return lineResult{interrupted: true} + case result := <-resultChan: + return result + } +} + +func (r *bufferedLineReader) AddHistory(string) {} + +func (r *bufferedLineReader) Close() error { return nil } + +func (r *readlineLineReader) ReadLine(context.Context) lineResult { + line, err := r.console.Readline() + if err != nil { + if stderrors.Is(err, readline.ErrInterrupt) { + return lineResult{interrupted: true} + } + if stderrors.Is(err, io.EOF) { + return lineResult{eof: true} + } + return lineResult{err: err} + } + + return lineResult{line: strings.TrimRight(line, "\r\n")} +} + +func (r *readlineLineReader) AddHistory(line string) { + _ = r.console.SaveHistory(line) +} + +func (r *readlineLineReader) Close() error { + return r.console.Close() +} + +func handleBuiltin(output io.Writer, statement string) (bool, bool) { + switch statement { + case ".help": + fmt.Fprintln(output, "Built-ins:") + fmt.Fprintln(output, " .help Show available shell commands") + fmt.Fprintln(output, " .exit Close the shell session") + fmt.Fprintln(output, " .quit Close the shell session") + return true, false + case ".exit", ".quit": + return true, true + default: + if strings.HasPrefix(statement, ".") { + fmt.Fprintf(output, "Unknown shell command: %s\n", statement) + fmt.Fprintln(output, "Use .help to see supported commands.") + return true, false + } + } + + return false, false +} + +func writeResult(output io.Writer, result ExecutionResult) { + if result.IsQuery { + writeQueryResult(output, result, outputWidth(output)) + fmt.Fprintf(output, "%d row(s)\n", len(result.Rows)) + return + } + + fmt.Fprintf(output, "OK (%d row(s) affected)\n", result.RowsAffected) +} + +func writeQueryResult(output io.Writer, result ExecutionResult, width int) { + if len(result.Columns) == 0 { + return + } + if shouldUseVerticalLayout(result, width) { + writeVerticalResult(output, result, width) + return + } + writeTableResult(output, result) +} + +func shouldUseVerticalLayout(result ExecutionResult, width int) bool { + columnWidths := calculateColumnWidths(result) + totalWidth := 1 + for _, columnWidth := range columnWidths { + totalWidth += columnWidth + 3 + } + + return totalWidth > normalizeRenderWidth(width) +} + +func writeTableResult(output io.Writer, result ExecutionResult) { + columnWidths := calculateColumnWidths(result) + separator := buildTableSeparator(columnWidths) + + fmt.Fprintln(output, separator) + writeTableRow(output, result.Columns, columnWidths) + fmt.Fprintln(output, separator) + for _, row := range result.Rows { + writeTableRow(output, row, columnWidths) + } + fmt.Fprintln(output, separator) +} + +func writeTableRow(output io.Writer, row []string, widths []int) { + formatted := make([]string, len(widths)) + for index, width := range widths { + value := "" + if index < len(row) { + value = normalizeCell(row[index]) + } + formatted[index] = padRight(truncateForWidth(value, width), width) + } + + fmt.Fprintf(output, "| %s |\n", strings.Join(formatted, " | ")) +} + +func buildTableSeparator(widths []int) string { + segments := make([]string, len(widths)) + for index, width := range widths { + segments[index] = strings.Repeat("-", width+2) + } + + return "+" + strings.Join(segments, "+") + "+" +} + +func writeVerticalResult(output io.Writer, result ExecutionResult, width int) { + labelWidth := 0 + for _, column := range result.Columns { + labelWidth = max(labelWidth, len(column)) + } + available := max(20, normalizeRenderWidth(width)-labelWidth-6) + + for rowIndex, row := range result.Rows { + header := fmt.Sprintf("-[ RECORD %d ]", rowIndex+1) + lineWidth := max(len(header)+1, normalizeRenderWidth(width)) + fmt.Fprintf(output, "%s%s\n", header, strings.Repeat("-", lineWidth-len(header))) + for columnIndex, column := range result.Columns { + value := "" + if columnIndex < len(row) { + value = row[columnIndex] + } + fmt.Fprintf(output, "%s | %s\n", padRight(column, labelWidth), truncateForWidth(normalizeCell(value), available)) + } + } + if len(result.Rows) == 0 { + fmt.Fprintln(output, "(no rows)") + } +} + +func calculateColumnWidths(result ExecutionResult) []int { + widths := make([]int, len(result.Columns)) + for index, column := range result.Columns { + widths[index] = min(maxTableColumnWidth, max(1, len(normalizeCell(column)))) + } + for _, row := range result.Rows { + for index := range result.Columns { + if index >= len(row) { + continue + } + widths[index] = min(maxTableColumnWidth, max(widths[index], len(normalizeCell(row[index])))) + } + } + + return widths +} + +func outputWidth(output io.Writer) int { + file, ok := output.(*os.File) + if !ok || !isTerminalFunc(file) { + return defaultRenderWidth + } + + width, _, err := terminalSizeFunc(file) + if err != nil || width <= 0 { + return defaultRenderWidth + } + + return width +} + +func normalizeRenderWidth(width int) int { + if width <= 0 { + return defaultRenderWidth + } + + return width +} + +func normalizeCell(value string) string { + replacer := strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\r", "\t", " ") + return replacer.Replace(value) +} + +func truncateForWidth(value string, width int) string { + if width <= 0 || len(value) <= width { + return value + } + if width <= 3 { + return value[:width] + } + + return value[:width-3] + "..." +} + +func padRight(value string, width int) string { + if len(value) >= width { + return value + } + + return value + strings.Repeat(" ", width-len(value)) +} + +func min(left, right int) int { + if left < right { + return left + } + + return right +} + +func max(left, right int) int { + if left > right { + return left + } + + return right +} + +func isQueryStatement(statement string) bool { + trimmed := strings.ToLower(strings.TrimSpace(statement)) + for _, prefix := range []string{"select", "pragma", "with", "show", "describe", "explain"} { + if strings.HasPrefix(trimmed, prefix) { + return true + } + } + + return false +} + +func stringifyValue(value any) string { + switch typed := value.(type) { + case nil: + return "NULL" + case []byte: + return string(typed) + default: + return fmt.Sprint(typed) + } +} diff --git a/internal/cli/pixie/db_shell_cmd/session_test.go b/internal/cli/pixie/db_shell_cmd/session_test.go new file mode 100644 index 0000000..bf81c7f --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/session_test.go @@ -0,0 +1,548 @@ +package db_shell_cmd + +import ( + "context" + "database/sql" + stderrors "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chzyer/readline" + helperdb "github.com/pixie-sh/database-helpers-go/database" +) + +type stubExecutor struct { + summary string +} + +type scriptedExecutor struct { + results map[string]ExecutionResult + errs map[string]error +} + +type fakeInteractiveConsole struct { + lines []string + errs []error + index int + history []string + closed bool +} + +func (s stubExecutor) Execute(context.Context, string) (ExecutionResult, error) { + return ExecutionResult{}, nil +} + +func (s stubExecutor) Close() error { + return nil +} + +func (s stubExecutor) Summary() string { + return s.summary +} + +func (s scriptedExecutor) Execute(_ context.Context, statement string) (ExecutionResult, error) { + if err, ok := s.errs[statement]; ok { + return ExecutionResult{}, err + } + if result, ok := s.results[statement]; ok { + return result, nil + } + return ExecutionResult{}, nil +} + +func (s scriptedExecutor) Close() error { + return nil +} + +func (s scriptedExecutor) Summary() string { + return "scripted" +} + +func (f *fakeInteractiveConsole) Readline() (string, error) { + if f.index < len(f.errs) && f.errs[f.index] != nil { + err := f.errs[f.index] + f.index++ + return "", err + } + if f.index >= len(f.lines) { + return "", io.EOF + } + line := f.lines[f.index] + f.index++ + return line, nil +} + +type fakeLineReader struct { + results []lineResult + index int + history []string + closed bool +} + +func (f *fakeLineReader) ReadLine(context.Context) lineResult { + if f.index >= len(f.results) { + return lineResult{eof: true} + } + result := f.results[f.index] + f.index++ + return result +} + +func (f *fakeLineReader) AddHistory(line string) { + f.history = append(f.history, line) +} + +func (f *fakeLineReader) Close() error { + f.closed = true + return nil +} + +func (f *fakeInteractiveConsole) SaveHistory(line string) error { + f.history = append(f.history, line) + return nil +} + +func (f *fakeInteractiveConsole) Close() error { + f.closed = true + return nil +} + +type fakeHelperConnection struct { + db *sql.DB + pingErr error + closeFn func() error +} + +func (f fakeHelperConnection) Ping() error { + return f.pingErr +} + +func (f fakeHelperConnection) Close() error { + if f.closeFn != nil { + return f.closeFn() + } + if f.db != nil { + return f.db.Close() + } + return nil +} + +func (f fakeHelperConnection) SQLDB() (*sql.DB, error) { + if f.db == nil { + return nil, stderrors.New("missing db") + } + return f.db, nil +} + +func restoreExecutorOpeners() { + openSQLiteExecutorFunc = openSQLiteExecutor + openHelperExecutorFunc = openHelperExecutor + openHelperConnection = func(ctx context.Context, cfg *helperdb.Configuration) (helperConnection, error) { + orm, err := helperdb.FactoryInstance.Create(ctx, cfg) + if err != nil { + return nil, err + } + + return helperConnectionAdapter{orm: orm}, nil + } + newReadlineInstance = func(cfg *readline.Config) (interactiveConsole, error) { + return readline.NewEx(cfg) + } + isTerminalFunc = func(file *os.File) bool { + return false + } + terminalSizeFunc = func(file *os.File) (int, int, error) { + return 0, 0, stderrors.New("not a terminal") + } + newLineReaderFunc = newLineReader +} + +func TestShellRunWithSQLiteSession(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "shell.db") + executor, err := OpenExecutor(context.Background(), ResolvedConfig{ + Driver: "sqlite", + DSN: "file:" + dbPath, + }) + if err != nil { + t.Fatalf("OpenExecutor() error = %v", err) + } + + input := strings.NewReader(strings.Join([]string{ + "create table users (id integer primary key, name text);", + "insert into users (name) values ('ada');", + "bad sql;", + "select name from users;", + ".exit", + }, "\n")) + + var stdout strings.Builder + var stderr strings.Builder + + shell := Shell{ + Executor: executor, + In: input, + Out: &stdout, + ErrOut: &stderr, + Prompt: "pixie-sql> ", + } + + if err := shell.Run(context.Background()); err != nil { + t.Fatalf("Shell.Run() error = %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "Connected to sqlite") { + t.Fatalf("stdout missing sqlite connection banner: %s", output) + } + if !strings.Contains(output, "ada") { + t.Fatalf("stdout missing query result: %s", output) + } + if !strings.Contains(output, "Session closed.") { + t.Fatalf("stdout missing session close message: %s", output) + } + if !strings.Contains(stderr.String(), "SQL error:") { + t.Fatalf("stderr missing SQL error output: %s", stderr.String()) + } +} + +func TestShellHelpBuiltin(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "help.db") + executor, err := OpenExecutor(context.Background(), ResolvedConfig{ + Driver: "sqlite", + DSN: "file:" + dbPath, + }) + if err != nil { + t.Fatalf("OpenExecutor() error = %v", err) + } + + var stdout strings.Builder + shell := Shell{ + Executor: executor, + In: strings.NewReader(".help\n.exit\n"), + Out: &stdout, + ErrOut: &stdout, + } + + if err := shell.Run(context.Background()); err != nil { + t.Fatalf("Shell.Run() error = %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "Built-ins:") { + t.Fatalf("help output missing built-ins header: %s", output) + } + if !strings.Contains(output, ".exit") { + t.Fatalf("help output missing .exit docs: %s", output) + } +} + +func TestOpenExecutorRoutesSQLiteToSQLiteOpener(t *testing.T) { + defer restoreExecutorOpeners() + + openHelperExecutorFunc = func(context.Context, ResolvedConfig) (Executor, error) { + t.Fatal("helper opener should not be used for sqlite") + return nil, nil + } + openSQLiteExecutorFunc = func(_ context.Context, cfg ResolvedConfig) (Executor, error) { + if cfg.Driver != "sqlite" { + t.Fatalf("sqlite opener driver = %q, want sqlite", cfg.Driver) + } + return stubExecutor{summary: "sqlite-route"}, nil + } + + executor, err := OpenExecutor(context.Background(), ResolvedConfig{Driver: "sqlite", DSN: defaultSQLiteDSN}) + if err != nil { + t.Fatalf("OpenExecutor() error = %v", err) + } + if summary := executor.Summary(); summary != "sqlite-route" { + t.Fatalf("Summary() = %q, want sqlite-route", summary) + } +} + +func TestOpenExecutorRoutesPostgresToHelperOpener(t *testing.T) { + defer restoreExecutorOpeners() + + openSQLiteExecutorFunc = func(context.Context, ResolvedConfig) (Executor, error) { + t.Fatal("sqlite opener should not be used for postgres") + return nil, nil + } + openHelperExecutorFunc = func(_ context.Context, cfg ResolvedConfig) (Executor, error) { + if cfg.Driver != "postgres" { + t.Fatalf("helper opener driver = %q, want postgres", cfg.Driver) + } + return stubExecutor{summary: "helper-route"}, nil + } + + executor, err := OpenExecutor(context.Background(), ResolvedConfig{Driver: "postgres"}) + if err != nil { + t.Fatalf("OpenExecutor() error = %v", err) + } + if summary := executor.Summary(); summary != "helper-route" { + t.Fatalf("Summary() = %q, want helper-route", summary) + } +} + +func TestOpenHelperExecutorBootstrapFailure(t *testing.T) { + defer restoreExecutorOpeners() + + openHelperConnection = func(context.Context, *helperdb.Configuration) (helperConnection, error) { + return nil, stderrors.New("factory boom") + } + + _, err := openHelperExecutor(context.Background(), ResolvedConfig{Driver: "postgres", DSN: "postgres://pixie"}) + if err == nil { + t.Fatal("openHelperExecutor() error = nil, want bootstrap failure") + } + if !strings.Contains(err.Error(), "failed to open helper-backed database connection") { + t.Fatalf("error = %q, want wrapped helper bootstrap failure", err.Error()) + } +} + +func TestOpenHelperExecutorPingFailureClosesConnection(t *testing.T) { + defer restoreExecutorOpeners() + + rawDB, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + + closed := false + openHelperConnection = func(context.Context, *helperdb.Configuration) (helperConnection, error) { + return fakeHelperConnection{ + db: rawDB, + pingErr: stderrors.New("ping boom"), + closeFn: func() error { + closed = true + return rawDB.Close() + }, + }, nil + } + + _, err = openHelperExecutor(context.Background(), ResolvedConfig{Driver: "postgres", DSN: "postgres://pixie"}) + if err == nil { + t.Fatal("openHelperExecutor() error = nil, want ping failure") + } + if !strings.Contains(err.Error(), "failed to connect to database") { + t.Fatalf("error = %q, want ping failure wrapper", err.Error()) + } + if !closed { + t.Fatal("connection was not closed after ping failure") + } +} + +func TestOpenHelperExecutorSuccessUsesHelperDatabase(t *testing.T) { + defer restoreExecutorOpeners() + + rawDB, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + t.Cleanup(func() { + _ = rawDB.Close() + }) + + var received *helperdb.Configuration + openHelperConnection = func(_ context.Context, cfg *helperdb.Configuration) (helperConnection, error) { + received = cfg + return fakeHelperConnection{db: rawDB}, nil + } + + executor, err := openHelperExecutor(context.Background(), ResolvedConfig{ + Driver: "postgres", + DSN: "host=localhost port=5432 dbname=pixie user=postgres sslmode=disable", + Host: "localhost", + Port: 5432, + Name: "pixie", + User: "postgres", + SSLMode: "disable", + }) + if err != nil { + t.Fatalf("openHelperExecutor() error = %v", err) + } + t.Cleanup(func() { + _ = executor.Close() + }) + + if received == nil { + t.Fatal("helper configuration was not forwarded") + } + if received.Driver != helperdb.GormDriver { + t.Fatalf("helper driver = %q, want %q", received.Driver, helperdb.GormDriver) + } + + if _, err := executor.Execute(context.Background(), "create table users (id integer primary key, name text);"); err != nil { + t.Fatalf("create table via helper-backed executor error = %v", err) + } + if _, err := executor.Execute(context.Background(), "insert into users (name) values ('ada');"); err != nil { + t.Fatalf("insert via helper-backed executor error = %v", err) + } + + result, err := executor.Execute(context.Background(), "select name from users;") + if err != nil { + t.Fatalf("query via helper-backed executor error = %v", err) + } + if len(result.Rows) != 1 || result.Rows[0][0] != "ada" { + t.Fatalf("query result = %#v, want row with ada", result.Rows) + } +} + +func TestBuildHelperConfigurationUsesDriverAndDSN(t *testing.T) { + cfg := buildHelperConfiguration(ResolvedConfig{ + Driver: "postgres", + DSN: "host=localhost port=5432 dbname=pixie user=postgres sslmode=disable", + }) + + if cfg.Driver != helperdb.GormDriver { + t.Fatalf("Driver = %q, want %q", cfg.Driver, helperdb.GormDriver) + } + + values, ok := cfg.Values.(*helperdb.GormDbConfiguration) + if !ok { + t.Fatalf("Values type = %T, want *database.GormDbConfiguration", cfg.Values) + } + + if values.Driver != helperdb.PsqlDriver { + t.Fatalf("Values.Driver = %q, want %q", values.Driver, helperdb.PsqlDriver) + } + + if values.Dsn != "host=localhost port=5432 dbname=pixie user=postgres sslmode=disable" { + t.Fatalf("Values.Dsn = %q, want source DSN", values.Dsn) + } +} + +func TestWriteResultUsesTableLayoutForNarrowResults(t *testing.T) { + var output strings.Builder + + writeResult(&output, ExecutionResult{ + Columns: []string{"id", "name"}, + Rows: [][]string{{"1", "Ada"}}, + IsQuery: true, + }) + + rendered := output.String() + if !strings.Contains(rendered, "+----+------+") { + t.Fatalf("table output missing separator: %s", rendered) + } + if !strings.Contains(rendered, "| id | name |") { + t.Fatalf("table output missing header row: %s", rendered) + } + if !strings.Contains(rendered, "| 1 | Ada |") { + t.Fatalf("table output missing data row: %s", rendered) + } +} + +func TestWriteQueryResultUsesVerticalLayoutWhenWide(t *testing.T) { + var output strings.Builder + + writeQueryResult(&output, ExecutionResult{ + Columns: []string{"id", "description", "metadata"}, + Rows: [][]string{{"1", strings.Repeat("x", 48), strings.Repeat("y", 48)}}, + IsQuery: true, + }, 40) + + rendered := output.String() + if !strings.Contains(rendered, "-[ RECORD 1 ]") { + t.Fatalf("vertical output missing record header: %s", rendered) + } + if !strings.Contains(rendered, "description | ") { + t.Fatalf("vertical output missing label/value row: %s", rendered) + } + if strings.Contains(rendered, "\t") { + t.Fatalf("vertical output should not contain tab-separated rendering: %s", rendered) + } +} + +func TestNewLineReaderUsesBufferedReaderWhenNotTTY(t *testing.T) { + defer restoreExecutorOpeners() + + reader, err := newLineReader(strings.NewReader("select 1;\n"), io.Discard, io.Discard, "pixie-sql> ") + if err != nil { + t.Fatalf("newLineReader() error = %v", err) + } + defer reader.Close() + + if _, ok := reader.(*bufferedLineReader); !ok { + t.Fatalf("reader type = %T, want *bufferedLineReader", reader) + } +} + +func TestNewLineReaderUsesReadlineForInteractiveTTY(t *testing.T) { + defer restoreExecutorOpeners() + + console := &fakeInteractiveConsole{} + newReadlineInstance = func(cfg *readline.Config) (interactiveConsole, error) { + if cfg.Prompt != "pixie-sql> " { + t.Fatalf("prompt = %q, want pixie-sql> ", cfg.Prompt) + } + if cfg.HistoryLimit != interactiveHistorySz { + t.Fatalf("history limit = %d, want %d", cfg.HistoryLimit, interactiveHistorySz) + } + if !cfg.DisableAutoSaveHistory { + t.Fatal("expected history autosave to remain disabled") + } + return console, nil + } + isTerminalFunc = func(file *os.File) bool { return true } + + stdin, err := os.Open("/dev/null") + if err != nil { + t.Fatalf("os.Open(/dev/null) error = %v", err) + } + defer stdin.Close() + stdout, err := os.OpenFile("/dev/null", os.O_WRONLY, 0) + if err != nil { + t.Fatalf("os.OpenFile(/dev/null) error = %v", err) + } + defer stdout.Close() + + reader, err := newLineReader(stdin, stdout, io.Discard, "pixie-sql> ") + if err != nil { + t.Fatalf("newLineReader() error = %v", err) + } + defer reader.Close() + + interactive, ok := reader.(*readlineLineReader) + if !ok { + t.Fatalf("reader type = %T, want *readlineLineReader", reader) + } + interactive.AddHistory("select 1;") + if len(console.history) != 1 || console.history[0] != "select 1;" { + t.Fatalf("history = %#v, want saved statement", console.history) + } +} + +func TestShellRunAddsInteractiveHistoryAndExecutesStatements(t *testing.T) { + defer restoreExecutorOpeners() + + reader := &fakeLineReader{results: []lineResult{{line: "select 1;"}, {line: ".exit"}}} + newLineReaderFunc = func(io.Reader, io.Writer, io.Writer, string) (lineReader, error) { + return reader, nil + } + + var rendered strings.Builder + shell := Shell{ + Executor: scriptedExecutor{results: map[string]ExecutionResult{ + "select 1;": {Columns: []string{"value"}, Rows: [][]string{{"1"}}, IsQuery: true}, + }}, + In: strings.NewReader(""), + Out: &rendered, + ErrOut: &rendered, + Prompt: "pixie-sql> ", + } + + if err := shell.Run(context.Background()); err != nil { + t.Fatalf("Shell.Run() error = %v", err) + } + if len(reader.history) != 2 || reader.history[0] != "select 1;" || reader.history[1] != ".exit" { + t.Fatalf("history = %#v, want both entered commands", reader.history) + } + if !reader.closed { + t.Fatal("line reader was not closed") + } + if !strings.Contains(rendered.String(), "| value |") { + t.Fatalf("rendered output missing query table: %s", rendered.String()) + } +} diff --git a/internal/cli/pixie/db_shell_cmd/signals_unix.go b/internal/cli/pixie/db_shell_cmd/signals_unix.go new file mode 100644 index 0000000..d7e3856 --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/signals_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package db_shell_cmd + +import ( + "os" + "syscall" +) + +var interruptSignals = []os.Signal{os.Interrupt, syscall.SIGTERM} diff --git a/internal/cli/pixie/db_shell_cmd/signals_windows.go b/internal/cli/pixie/db_shell_cmd/signals_windows.go new file mode 100644 index 0000000..1d6d16e --- /dev/null +++ b/internal/cli/pixie/db_shell_cmd/signals_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package db_shell_cmd + +import "os" + +var interruptSignals = []os.Signal{os.Interrupt} diff --git a/pkg/commands/db_shell.go b/pkg/commands/db_shell.go new file mode 100644 index 0000000..70de859 --- /dev/null +++ b/pkg/commands/db_shell.go @@ -0,0 +1,13 @@ +// Package commands provides public access to pixie-cli commands for embedding in other CLIs. +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/pixie-sh/pixie-cli/internal/cli/pixie/db_shell_cmd" +) + +// DBShellCmd returns the interactive database shell command. +func DBShellCmd() *cobra.Command { + return db_shell_cmd.Cmd() +}