diff --git a/.gitignore b/.gitignore index aaadf73..e28278c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +/cloud-cli # Test binary, built with `go test -c` *.test @@ -18,7 +19,7 @@ coverage.* profile.cov # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ # Go workspace file go.work @@ -28,5 +29,9 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ + +# bots and other local information +.local/ +.claude/ \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..34c203f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @temporalio/saas will be requested for review when +# someone opens a pull request. +* @temporalio/saas diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ec5f1b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: all gen build + +all: gen build + +gen: temporalcloudcli/commands.gen.go + +temporalcloudcli/commands.gen.go: temporalcloudcli/commandsgen/commands.yml + go run ./temporalcloudcli/internal/cmd/gen-commands + +build: + go build ./cmd/cloud-cli diff --git a/README.md b/README.md index b009473..818db9a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# temporal-cloud +# cloud-cli CLI Plugin for Temporal Cloud diff --git a/cmd/cloud-cli/main.go b/cmd/cloud-cli/main.go new file mode 100644 index 0000000..5757d2c --- /dev/null +++ b/cmd/cloud-cli/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "context" + + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + temporalcloudcli.Execute(ctx, temporalcloudcli.CommandOptions{}) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a942c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,61 @@ +module github.com/temporalio/cloud-cli + +go 1.25.3 + +require ( + github.com/dustin/go-humanize v1.0.1 + github.com/fatih/color v1.18.0 + github.com/olekukonko/tablewriter v0.0.5 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + github.com/temporalio/cli v1.5.1 + github.com/temporalio/ui-server/v2 v2.42.1 + go.temporal.io/api v1.57.0 + go.temporal.io/cloud-sdk v0.6.0 + go.temporal.io/sdk v1.37.0 + go.temporal.io/sdk/contrib/envconfig v0.1.0 + go.temporal.io/server v1.29.1 + golang.org/x/oauth2 v0.30.0 + golang.org/x/term v0.32.0 + google.golang.org/grpc v1.72.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.7.0-rc.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/labstack/echo/v4 v4.13.4 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/nexus-rpc/sdk-go v0.3.0 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/sys v0.37.0 // indirect + google.golang.org/protobuf v1.36.10 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..58fb3c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,172 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/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/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/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-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/nexus-rpc/sdk-go v0.3.0 h1:Y3B0kLYbMhd4C2u00kcYajvmOrfozEtTV/nHSnV57jA= +github.com/nexus-rpc/sdk-go v0.3.0/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/temporalio/cli v1.5.1 h1:0RZMk14BWxz44Xg6ciDQvpfAMffoG3J7/K03ql80teY= +github.com/temporalio/cli v1.5.1/go.mod h1:8lfULNGZ1Y2sobXSpY+2qQ4vgR4hYLuTLpqnCIl9Au4= +github.com/temporalio/ui-server/v2 v2.42.1 h1:ajeOxqCnUiCRQQhQYLxaT7wUgF/slqZJtdW4pLjVqCs= +github.com/temporalio/ui-server/v2 v2.42.1/go.mod h1:lKTnn50t8yQvcrarxAOjX33YcfkomkiNB5BH06wQwEE= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.temporal.io/api v1.57.0 h1:vJGbU6RqMqCAXP03Jq4KEL61sCxAdJx4Yj7PxtbsrF0= +go.temporal.io/api v1.57.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/cloud-sdk v0.6.0 h1:14xDxRMJmz3alC4bO2qTeCoa5j3l2/M48/5nF5Qmrm4= +go.temporal.io/cloud-sdk v0.6.0/go.mod h1:AueDDyuayosk+zalfrnuftRqnRQTHwD0HYwNgEQc0YE= +go.temporal.io/sdk v1.37.0 h1:RbwCkUQuqY4rfCzdrDZF9lgT7QWG/pHlxfZFq0NPpDQ= +go.temporal.io/sdk v1.37.0/go.mod h1:tOy6vGonfAjrpCl6Bbw/8slTgQMiqvoyegRv2ZHPm5M= +go.temporal.io/sdk/contrib/envconfig v0.1.0 h1:s+G/Ujph+Xl2jzLiiIm2T1vuijDkUL4Kse49dgDVGBE= +go.temporal.io/sdk/contrib/envconfig v0.1.0/go.mod h1:FQEO3C56h9C7M6sDgSanB8HnBTmopw9qgVx4F1S6pJk= +go.temporal.io/server v1.29.1 h1:sgiMveg/8e51lA62pWb9VzC5WTRitKFyT6XsjonHUhE= +go.temporal.io/server v1.29.1/go.mod h1:pc0n6DRcN06V4WNhaxdxE3KaZIS3KSDNKdca6uu6RuU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go new file mode 100644 index 0000000..b2eb279 --- /dev/null +++ b/temporalcloudcli/cloud.go @@ -0,0 +1,25 @@ +package temporalcloudcli + +import ( + "go.temporal.io/cloud-sdk/cloudclient" +) + +func newCloudClient(cctx *CommandContext) (*cloudclient.Client, error) { + opts := cloudclient.Options{} + if cctx.RootCommand.ApiKey != "" { + opts.APIKey = cctx.RootCommand.ApiKey + } else { + tokenConfig, err := loadTokenConfig(cctx) + if err != nil { + return nil, err + } + opts.APIKeyReader = tokenConfig + } + + cloudClient, err := cloudclient.New(opts) + if err != nil { + return nil, err + } + + return cloudClient, nil +} diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go new file mode 100644 index 0000000..ddd1d8f --- /dev/null +++ b/temporalcloudcli/commands.gen.go @@ -0,0 +1,209 @@ +// Code generated. DO NOT EDIT. + +package temporalcloudcli + +import ( + "github.com/mattn/go-isatty" + + "github.com/spf13/cobra" + + "os" +) + +var hasHighlighting = isatty.IsTerminal(os.Stdout.Fd()) + +type CloudCommand struct { + Command cobra.Command + ConfigFile string + Profile string + DisableConfigFile bool + DisableConfigEnv bool + LogLevel StringEnum + LogFormat StringEnum + Output StringEnum + TimeFormat StringEnum + Color StringEnum + NoJsonShorthandPayloads bool + CommandTimeout Duration + ClientConnectTimeout Duration + Domain string + Audience string + ClientId string + ConfigDir string + DisablePopUp bool + ApiKey string +} + +func NewCloudCommand(cctx *CommandContext) *CloudCommand { + var s CloudCommand + s.Command.Use = "cloud" + s.Command.Short = "Temporal Cloud command-line interface" + if hasHighlighting { + s.Command.Long = "The Temporal Cloud CLI provides management and operations for Temporal Cloud.\n\nExample:\n\n\x1b[1mcloud namespace\x1b[0m" + } else { + s.Command.Long = "The Temporal Cloud CLI provides management and operations for Temporal Cloud.\n\nExample:\n\n```\ncloud namespace\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudLoginCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceCommand(cctx, &s).Command) + s.Command.PersistentFlags().StringVar(&s.ConfigFile, "config-file", "", "File path to read TOML config from, defaults to `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is defined as `$HOME/.config` on Unix, \"$HOME/Library/Application Support\" on macOS, and %AppData% on Windows. EXPERIMENTAL.") + s.Command.PersistentFlags().StringVar(&s.Profile, "profile", "", "Profile to use for config file. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigFile, "disable-config-file", false, "If set, disables loading environment config from config file. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigEnv, "disable-config-env", false, "If set, disables loading environment config from environment variables. EXPERIMENTAL.") + s.LogLevel = NewStringEnum([]string{"debug", "info", "warn", "error", "never"}, "info") + s.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Log level. Default is \"info\" for most commands and \"warn\" for `server start-dev`. Accepted values: debug, info, warn, error, never.") + s.LogFormat = NewStringEnum([]string{"text", "json", "pretty"}, "text") + s.Command.PersistentFlags().Var(&s.LogFormat, "log-format", "Log format. Accepted values: text, json.") + s.Output = NewStringEnum([]string{"text", "json", "jsonl", "none"}, "text") + s.Command.PersistentFlags().VarP(&s.Output, "output", "o", "Non-logging data output format. Accepted values: text, json, jsonl, none.") + s.TimeFormat = NewStringEnum([]string{"relative", "iso", "raw"}, "relative") + s.Command.PersistentFlags().Var(&s.TimeFormat, "time-format", "Time format. Accepted values: relative, iso, raw.") + s.Color = NewStringEnum([]string{"always", "never", "auto"}, "auto") + s.Command.PersistentFlags().Var(&s.Color, "color", "Output coloring. Accepted values: always, never, auto.") + s.Command.PersistentFlags().BoolVar(&s.NoJsonShorthandPayloads, "no-json-shorthand-payloads", false, "Raw payload output, even if the JSON option was used.") + s.CommandTimeout = 0 + s.Command.PersistentFlags().Var(&s.CommandTimeout, "command-timeout", "The command execution timeout. 0s means no timeout.") + s.ClientConnectTimeout = 0 + s.Command.PersistentFlags().Var(&s.ClientConnectTimeout, "client-connect-timeout", "The client connection timeout. 0s means no timeout.") + s.Command.PersistentFlags().StringVar(&s.Domain, "domain", "login.tmprl.cloud", "The domain to log into.") + s.Command.PersistentFlags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl.cloud", "Used for login.") + s.Command.PersistentFlags().StringVar(&s.ClientId, "client-id", "", "Used for login.") + s.Command.PersistentFlags().StringVar(&s.ConfigDir, "config-dir", "", "The directory to store the config into.") + s.Command.PersistentFlags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Disable browser pop-up.") + s.Command.PersistentFlags().StringVar(&s.ApiKey, "api-key", "", "The api key to use for auth.") + s.initCommand(cctx) + return &s +} + +type CloudLoginCommand struct { + Parent *CloudCommand + Command cobra.Command +} + +func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLoginCommand { + var s CloudLoginCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "login [flags]" + s.Command.Short = "Log into temporal cloud" + s.Command.Long = "Log into temporal cloud." + s.Command.Args = cobra.NoArgs + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceCommand struct { + Parent *CloudCommand + Command cobra.Command +} + +func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *CloudNamespaceCommand { + var s CloudNamespaceCommand + s.Parent = parent + s.Command.Use = "namespace" + s.Command.Short = "Manage namespaces" + s.Command.Long = "Commands for managing namespaces." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceApplyCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEditCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceGetCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceApplyCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Spec string + DryRun bool + AsyncOperationId string + Idemptotent bool + Async bool +} + +func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceApplyCommand { + var s CloudNamespaceApplyCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "apply [flags]" + s.Command.Short = "Create or update a namespace" + if hasHighlighting { + s.Command.Long = "Apply a namespace configuration to Temporal Cloud. This command creates a\nnew namespace or updates an existing one based on the provided specification.\n\nYou can specify the namespace configuration using a JSON specification,\nprovided either inline or as a file path.\n\nExample with inline JSON:\n\n\x1b[1mcloud namespace apply --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\x1b[0m\n\nExample with file path:\n\n\x1b[1mcloud namespace apply --spec @namespace-spec.json\x1b[0m" + } else { + s.Command.Long = "Apply a namespace configuration to Temporal Cloud. This command creates a\nnew namespace or updates an existing one based on the provided specification.\n\nYou can specify the namespace configuration using a JSON specification,\nprovided either inline or as a file path.\n\nExample with inline JSON:\n\n```\ncloud namespace apply --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\n```\n\nExample with file path:\n\n```\ncloud namespace apply --spec @namespace-spec.json\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Spec, "spec", "s", "", "JSON specification for the namespace configuration. Can be provided as inline JSON or as a file path. If the value starts with '@', it will be treated as a file path (e.g., '@config.json'). Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "spec") + s.Command.Flags().BoolVar(&s.DryRun, "dry-run", false, "Validate the configuration without applying changes. Shows what would be created or updated.") + s.Command.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "The async operation id to use for the request, optional.") + s.Command.Flags().BoolVarP(&s.Idemptotent, "idemptotent", "i", false, "Determines whether the command should error if there's nothing that has changed.") + s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Determines whether the command should return immediately with the async operation or wait until it completes.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceEditCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + AsyncOperationId string + Idemptotent bool + Async bool +} + +func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceEditCommand { + var s CloudNamespaceEditCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "edit [flags]" + s.Command.Short = "Edit a namespace" + if hasHighlighting { + s.Command.Long = "Edit a namespace spec on Temporal Cloud. This command updates a namespace with changes\nspecified by the user in an edit operation.\n\nExample:\n\n\x1b[1mcloud namespace edit --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Edit a namespace spec on Temporal Cloud. This command updates a namespace with changes\nspecified by the user in an edit operation.\n\nExample:\n\n```\ncloud namespace edit --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The namespace to get, including the account. For example my-namespace.my-account. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "The async operation id to use for the request, optional.") + s.Command.Flags().BoolVarP(&s.Idemptotent, "idemptotent", "i", false, "Determines whether the command should error if there's nothing that has changed.") + s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Determines whether the command should return immediately with the async operation or wait until it completes.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceGetCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string +} + +func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceGetCommand { + var s CloudNamespaceGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Manage namespaces" + s.Command.Long = "Get a namespace from temporal cloud." + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The namespace to get, including the account. For example my-namespace.my-account. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go new file mode 100644 index 0000000..ac8ea61 --- /dev/null +++ b/temporalcloudcli/commands.go @@ -0,0 +1,573 @@ +package temporalcloudcli + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "slices" + "strings" + "syscall" + "time" + + "github.com/dustin/go-humanize" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" + "github.com/temporalio/ui-server/v2/server/version" + "go.temporal.io/api/common/v1" + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/api/failure/v1" + "go.temporal.io/api/temporalproto" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/converter" + "go.temporal.io/sdk/temporal" + "go.temporal.io/server/common/headers" + "golang.org/x/term" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Version is the value put as the default command version. This is often +// replaced at build time via ldflags. +var Version = "0.0.0-DEV" + +type CommandContext struct { + // This context is closed on interrupt + context.Context + Options CommandOptions + DeprecatedEnvConfigValues map[string]map[string]string + FlagsWithEnvVars []*pflag.Flag + + // These values may not be available until after pre-run of main command + Printer *printer.Printer + Logger *slog.Logger + JSONOutput bool + JSONShorthandPayloads bool + + // Is set to true if any command actually started running. This is a hack to workaround the fact + // that cobra does not properly exit nonzero if an unknown command/subcommand is given. + ActuallyRanCommand bool + + // Root/current command only set inside of pre-run + RootCommand *CloudCommand + CurrentCommand *cobra.Command +} + +type CommandOptions struct { + // If empty, assumed to be os.Args[1:] + Args []string + // If nil, [envconfig.EnvLookupOS] is used. + EnvLookup envconfig.EnvLookup + + // These three fields below default to OS values + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + + // Defaults to logging error then os.Exit(1) + Fail func(error) + + AdditionalClientGRPCDialOptions []grpc.DialOption + ClientConnectTimeout time.Duration +} + +// NewCommandContext creates a CommandContext for use by the rest of the CLI. +// Among other things, this parses the env config file and modifies +// options/flags according to the parameters set there. +// +// A CommandContext and CancelFunc are always returned, even in the event of an +// error; this is so the CommandContext can be used to print an appropriate +// error message. +func NewCommandContext(ctx context.Context, options CommandOptions) (*CommandContext, context.CancelFunc, error) { + cctx := &CommandContext{Context: ctx, Options: options} + if err := cctx.preprocessOptions(); err != nil { + return cctx, func() {}, err + } + + // Setup interrupt handler + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + cctx.Context = ctx + return cctx, stop, nil +} + +func (c *CommandContext) preprocessOptions() error { + if len(c.Options.Args) == 0 { + c.Options.Args = os.Args[1:] + } + if c.Options.EnvLookup == nil { + c.Options.EnvLookup = envconfig.EnvLookupOS + } + + if c.Options.Stdin == nil { + c.Options.Stdin = os.Stdin + } + if c.Options.Stdout == nil { + c.Options.Stdout = os.Stdout + } + if c.Options.Stderr == nil { + c.Options.Stderr = os.Stderr + } + + // Setup default fail callback + if c.Options.Fail == nil { + c.Options.Fail = func(err error) { + // If context is closed, say that the program was interrupted and ignore + // the actual error + if c.Err() != nil { + err = fmt.Errorf("program interrupted") + } + if c.Logger != nil { + c.Logger.Error(err.Error()) + } else { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(1) + } + } + + return nil +} + +const flagEnvVarAnnotation = "__temporal_env_var" + +func (c *CommandContext) BindFlagEnvVar(flag *pflag.Flag, envVar string) { + if flag.Annotations == nil { + flag.Annotations = map[string][]string{} + } + flag.Annotations[flagEnvVarAnnotation] = []string{envVar} + c.FlagsWithEnvVars = append(c.FlagsWithEnvVars, flag) +} + +func (c *CommandContext) MarshalFriendlyJSONPayloads(m *common.Payloads) (json.RawMessage, error) { + if m == nil { + return []byte("null"), nil + } + // Use one if there's one, otherwise just serialize whole thing + if p := m.GetPayloads(); len(p) == 1 { + return c.MarshalProtoJSON(p[0]) + } + return c.MarshalProtoJSON(m) +} + +// Starts with newline +func (c *CommandContext) MarshalFriendlyFailureBodyText(f *failure.Failure, indent string) (s string) { + for f != nil { + s += "\n" + indent + "Message: " + f.Message + if f.StackTrace != "" { + s += "\n" + indent + "StackTrace:\n" + indent + " " + + strings.Join(strings.Split(f.StackTrace, "\n"), "\n"+indent+" ") + } + if f = f.Cause; f != nil { + s += "\n" + indent + "Cause:" + indent += " " + } + } + return +} + +// Takes payload shorthand into account, can use +// MarshalProtoJSONNoPayloadShorthand if needed +func (c *CommandContext) MarshalProtoJSON(m proto.Message) ([]byte, error) { + return c.MarshalProtoJSONWithOptions(m, c.JSONShorthandPayloads) +} + +func (c *CommandContext) MarshalProtoJSONWithOptions(m proto.Message, jsonShorthandPayloads bool) ([]byte, error) { + opts := temporalproto.CustomJSONMarshalOptions{Indent: c.Printer.JSONIndent} + if jsonShorthandPayloads { + opts.Metadata = map[string]any{common.EnablePayloadShorthandMetadataKey: true} + } + return opts.Marshal(m) +} + +func (c *CommandContext) UnmarshalProtoJSON(b []byte, m proto.Message) error { + return UnmarshalProtoJSONWithOptions(b, m, c.JSONShorthandPayloads) +} + +func UnmarshalProtoJSONWithOptions(b []byte, m proto.Message, jsonShorthandPayloads bool) error { + opts := temporalproto.CustomJSONUnmarshalOptions{DiscardUnknown: true} + if jsonShorthandPayloads { + opts.Metadata = map[string]any{common.EnablePayloadShorthandMetadataKey: true} + } + return opts.Unmarshal(b, m) +} + +// Set flag values from environment file & variables. Returns a callback to log anything interesting +// since logging will not yet be initialized when this runs. +func (c *CommandContext) populateFlagsFromEnv(flags *pflag.FlagSet) (func(*slog.Logger), error) { + if flags == nil { + return func(logger *slog.Logger) {}, nil + } + var logCalls []func(*slog.Logger) + var flagErr error + flags.VisitAll(func(flag *pflag.Flag) { + // If the flag was already changed by the user, we don't overwrite + if flagErr != nil || flag.Changed { + return + } + + if anns := flag.Annotations[flagEnvVarAnnotation]; len(anns) == 1 { + if envVal, _ := c.Options.EnvLookup.LookupEnv(anns[0]); envVal != "" { + if err := flag.Value.Set(envVal); err != nil { + flagErr = fmt.Errorf("failed setting flag %v with env name %v and value %v: %w", + flag.Name, anns[0], envVal, err) + return + } + if flag.Changed { + logCalls = append(logCalls, func(l *slog.Logger) { + l.Info("Env var overrode --env setting", "env_var", anns[0], "flag", flag.Name) + }) + } + flag.Changed = true + } + } + }) + logFn := func(logger *slog.Logger) { + for _, call := range logCalls { + call(logger) + } + } + return logFn, flagErr +} + +// Returns error if JSON output enabled +func (c *CommandContext) promptYes(message string, autoConfirm bool) (bool, error) { + if c.JSONOutput && !autoConfirm { + return false, fmt.Errorf("must bypass prompts when using JSON output") + } + c.Printer.Print(message, " ") + if autoConfirm { + c.Printer.Println("yes") + return true, nil + } + line, _ := bufio.NewReader(c.Options.Stdin).ReadString('\n') + line = strings.TrimSpace(strings.ToLower(line)) + return line == "y" || line == "yes", nil +} + +// Returns error if JSON output enabled +func (c *CommandContext) promptString(message string, expected string, autoConfirm bool) (bool, error) { + if c.JSONOutput && !autoConfirm { + return false, fmt.Errorf("must bypass prompts when using JSON output") + } + c.Printer.Print(message, " ") + if autoConfirm { + c.Printer.Println(expected) + return true, nil + } + line, _ := bufio.NewReader(c.Options.Stdin).ReadString('\n') + line = strings.TrimSpace(line) + return line == expected, nil +} + +// Execute runs the Temporal CLI with the given context and options. This +// intentionally does not return an error but rather invokes Fail on the +// options. +func Execute(ctx context.Context, options CommandOptions) { + // Create context and run. We always get a context and cancel func back even + // if an error was returned. This is so we can use the context to print an + // error message using the appropriate Fail() method, regardless of why the + // failure occurred. + // + // (In most cases, an error here likely means a problem with the user's env + // config file, or some other issue in their environment.) + cctx, cancel, err := NewCommandContext(ctx, options) + defer cancel() + + if err == nil { + // We have a context; let's actually run the command. + cmd := NewCloudCommand(cctx) + cmd.Command.SetArgs(cctx.Options.Args) + err = cmd.Command.ExecuteContext(cctx) + } + + if err != nil { + // Either we failed to create the context, OR the command itself failed. + // Either way, we need to print an error message. + cctx.Options.Fail(err) + } + + // If no command ever actually got run, exit nonzero with an error. This is + // an ugly hack to make sure that iff the user explicitly asked for help, we + // exit with a zero error code. (The other situation in which help is + // printed is when the user invokes an unknown command--we still want a + // non-zero exit in that case.) We should revisit this if/when the + // following Cobra issues get fixed: + // + // - https://github.com/spf13/cobra/issues/1156 + // - https://github.com/spf13/cobra/issues/706 + if !cctx.ActuallyRanCommand { + zeroExitArgs := []string{"--help", "-h", "--version", "-v", "help"} + if slices.ContainsFunc(cctx.Options.Args, func(a string) bool { + return slices.Contains(zeroExitArgs, a) + }) { + return + } + cctx.Options.Fail(fmt.Errorf("unknown command")) + } +} + +// getUsageTemplate returns a custom usage template with proper flag wrapping +// The default template can be found here: https://github.com/spf13/cobra/blob/v1.9.1/command.go#L1937-L1966 +func getUsageTemplate() string { + // Get terminal width, default to 80 if unable to determine + width := 80 + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + width = w + } + + // Use width - 1 for wrapping to avoid edge cases + flagWidth := width - 1 + + // Custom template that uses FlagUsagesWrapped for proper indentation + return fmt.Sprintf(`Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsagesWrapped %d | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsagesWrapped %d | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, flagWidth, flagWidth) +} + +func (c *CloudCommand) initCommand(cctx *CommandContext) { + c.Command.Version = VersionString() + + // Set custom usage template with proper flag wrapping + c.Command.SetUsageTemplate(getUsageTemplate()) + + // Unfortunately color is a global option, so we can set in pre-run but we + // must unset in post-run + origNoColor := color.NoColor + c.Command.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Set command + cctx.CurrentCommand = cmd + // Populate environ. We will make the error return here which will cause + // usage to be printed. + logCalls, err := cctx.populateFlagsFromEnv(cmd.Flags()) + if err != nil { + return err + } + + // Default color.NoColor global is equivalent to "auto" so only override if + // never or always + if c.Color.Value == "never" || c.Color.Value == "always" { + color.NoColor = c.Color.Value == "never" + } + + res := c.preRun(cctx) + + logCalls(cctx.Logger) + + // Always disable color if JSON output is on (must be run after preRun so JSONOutput is set) + if cctx.JSONOutput { + color.NoColor = true + } + cctx.ActuallyRanCommand = true + + return res + } + c.Command.PersistentPostRun = func(*cobra.Command, []string) { + color.NoColor = origNoColor + } +} + +var buildInfo string + +func VersionString() string { + // To add build-time information to the version string, use + // go build -ldflags "-X github.com/temporalio/cli/temporalcli.buildInfo=" + var bi = buildInfo + if bi != "" { + bi = fmt.Sprintf(", %s", bi) + } + return fmt.Sprintf("%s (Server %s, UI %s%s)", Version, headers.ServerVersion, version.UIVersion, bi) +} + +func (c *CloudCommand) preRun(cctx *CommandContext) error { + // Set this command as the root + cctx.RootCommand = c + + // Configure logger if not already on context + if cctx.Logger == nil { + // If level is never, make noop logger + if c.LogLevel.Value == "never" { + cctx.Logger = newNopLogger() + } else { + var level slog.Level + if err := level.UnmarshalText([]byte(c.LogLevel.Value)); err != nil { + return fmt.Errorf("invalid log level %q: %w", c.LogLevel.Value, err) + } + var handler slog.Handler + switch c.LogFormat.Value { + // We have a "pretty" alias for compatibility + case "", "text", "pretty": + handler = slog.NewTextHandler(cctx.Options.Stderr, &slog.HandlerOptions{ + Level: level, + // Remove the TZ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && a.Value.Kind() == slog.KindTime { + a.Value = slog.StringValue(a.Value.Time().Format("2006-01-02T15:04:05.000")) + } + return a + }, + }) + case "json": + handler = slog.NewJSONHandler(cctx.Options.Stderr, &slog.HandlerOptions{Level: level}) + default: + return fmt.Errorf("invalid log format %q", c.LogFormat.Value) + } + cctx.Logger = slog.New(handler) + } + } + + // Configure printer if not already on context + cctx.JSONOutput = c.Output.Value == "json" || c.Output.Value == "jsonl" + // Only indent JSON if not jsonl + var jsonIndent string + if c.Output.Value == "json" { + jsonIndent = " " + } + if cctx.Printer == nil { + printerOutput := cctx.Options.Stdout + // Disable printer by making writer noop if "none" chosen + if c.Output.Value == "none" { + printerOutput = nopWriter{} + } + cctx.Printer = &printer.Printer{ + Output: printerOutput, + JSON: cctx.JSONOutput, + JSONIndent: jsonIndent, + JSONPayloadShorthand: !c.NoJsonShorthandPayloads, + } + switch c.TimeFormat.Value { + case "iso": + cctx.Printer.FormatTime = func(t time.Time) string { return t.Format(time.RFC3339) } + case "raw": + cctx.Printer.FormatTime = func(t time.Time) string { return fmt.Sprintf("%v", t) } + case "relative": + cctx.Printer.FormatTime = humanize.Time + default: + return fmt.Errorf("invalid time format %q", c.TimeFormat.Value) + } + } + cctx.JSONShorthandPayloads = !c.NoJsonShorthandPayloads + if c.CommandTimeout.Duration() > 0 { + cctx.Context, _ = context.WithTimeoutCause( + cctx.Context, + c.CommandTimeout.Duration(), + fmt.Errorf("command timed out after %v", c.CommandTimeout.Duration()), + ) + } + if c.ClientConnectTimeout.Duration() > 0 { + cctx.Options.ClientConnectTimeout = c.ClientConnectTimeout.Duration() + } + + return nil +} + +func aliasNormalizer(aliases map[string]string) func(f *pflag.FlagSet, name string) pflag.NormalizedName { + return func(f *pflag.FlagSet, name string) pflag.NormalizedName { + if actual := aliases[name]; actual != "" { + name = actual + } + return pflag.NormalizedName(name) + } +} + +func newNopLogger() *slog.Logger { return slog.New(discardLogHandler{}) } + +type discardLogHandler struct{} + +func (discardLogHandler) Enabled(context.Context, slog.Level) bool { return false } +func (discardLogHandler) Handle(context.Context, slog.Record) error { return nil } +func (d discardLogHandler) WithAttrs([]slog.Attr) slog.Handler { return d } +func (d discardLogHandler) WithGroup(string) slog.Handler { return d } + +func timestampToTime(t *timestamppb.Timestamp) time.Time { + if t == nil { + return time.Time{} + } + return t.AsTime() +} + +type nopWriter struct{} + +func (nopWriter) Write(b []byte) (int, error) { return len(b), nil } + +type structuredError struct { + Message string `json:"message"` + Type string `json:"type,omitempty"` + Details any `json:"details,omitempty"` +} + +func fromApplicationError(err *temporal.ApplicationError) (*structuredError, error) { + var deets any + if err := err.Details(&deets); err != nil && !errors.Is(err, temporal.ErrNoData) { + return nil, err + } + return &structuredError{ + Message: err.Error(), + Type: err.Type(), + Details: deets, + }, nil +} + +func encodeMapToPayloads(in map[string]any) (map[string]*commonpb.Payload, error) { + if len(in) == 0 { + return nil, nil + } + // search attributes always use default dataconverter + dc := converter.GetDefaultDataConverter() + out := make(map[string]*commonpb.Payload, len(in)) + for key, val := range in { + payload, err := dc.ToPayload(val) + if err != nil { + return nil, err + } + out[key] = payload + } + return out, nil +} + +type overrideDisplayTypeFlagValue struct { + pflag.Value + displayType string +} + +func (o *overrideDisplayTypeFlagValue) Type() string { + return o.displayType +} + +func overrideFlagDisplayType(flag *pflag.Flag, displayType string) { + flag.Value = &overrideDisplayTypeFlagValue{Value: flag.Value, displayType: displayType} +} diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go new file mode 100644 index 0000000..92d3435 --- /dev/null +++ b/temporalcloudcli/commands.login.go @@ -0,0 +1,6 @@ +package temporalcloudcli + +func (c *CloudLoginCommand) run(cctx *CommandContext, args []string) error { + _, err := login(cctx, nil) + return err +} diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go new file mode 100644 index 0000000..ad46bbf --- /dev/null +++ b/temporalcloudcli/commands.namespace.go @@ -0,0 +1,185 @@ +package temporalcloudcli + +import ( + "fmt" + + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, args []string) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + n, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) +} + +func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + ns, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + newSpec := &namespace.NamespaceSpec{} + err = runEditorForJSONEditForProtos(ns.Spec, newSpec) + if err != nil { + return err + } + + asyncOp, err := client.updateNamespace(cctx.Context, newSpec, updateNamespaceParams{ + asyncOperationID: c.AsyncOperationId, + resourceVersion: ns.ResourceVersion, + namespace: c.Namespace, + idempotent: c.Idemptotent, + }) + if err != nil { + return err + } + + // TODO: (gmankes) remove this -- clean up and make shareable + if asyncOp == nil { + // Nothing changed (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "unchanged", + Namespace: newSpec.Name, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + } + + // Poll for completion + return pollAsyncOperation(cctx, asyncOp.Id) +} + +func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) error { + // Step 1: Load spec from file or inline + specData, err := loadJSONSpec(c.Spec) + if err != nil { + return err + } + + // Step 2: Parse JSON into NamespaceSpec using protobuf JSON unmarshaling + spec := &namespace.NamespaceSpec{} + if err := cctx.UnmarshalProtoJSON(specData, spec); err != nil { + return fmt.Errorf("failed to parse JSON spec: %w", err) + } + + // Validate namespace name is present + if spec.Name == "" { + return fmt.Errorf("namespace name must be provided either via --namespace flag or in the spec") + } + + // Step 4: Create cloud and namespace clients + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + // Step 5: Handle dry-run mode + if c.DryRun { + return c.performDryRun(cctx, client, spec) + } + + // Step 6: Apply the namespace (create or update) + params := applyNamespaceParams{ + asyncOperationID: c.AsyncOperationId, // Use the flag value if provided + idempotent: c.Idemptotent, // Use the flag value + } + + asyncOp, err := client.applyNamespace(cctx.Context, spec, params) + if err != nil { + return fmt.Errorf("failed to apply namespace: %w", err) + } + + // Step 7: Handle result + if asyncOp == nil { + // Nothing changed (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "unchanged", + Namespace: spec.Name, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Step 8: Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + } + + // Step 9: Poll for completion + return pollAsyncOperation(cctx, asyncOp.Id) +} + +// TODO: (gmankes) make this --diff and have a diff, also make it shareable +func (c *CloudNamespaceApplyCommand) performDryRun(cctx *CommandContext, client *namespaceClient, spec *namespace.NamespaceSpec) error { + // Try to get existing namespace + namespaces, err := client.listNamespacesWithName(cctx.Context, spec.Name) + if err != nil { + return err + } else if len(namespaces) > 1 { + return fmt.Errorf("multiple namespaces match namespace name: %s", spec.GetName()) + } else if len(namespaces) == 0 { + // Namespace doesn't exist - would create + result := struct { + DryRun bool + Action string + Namespace string + Spec *namespace.NamespaceSpec + }{ + DryRun: true, + Action: "create", + Namespace: spec.Name, + Spec: spec, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + existing := namespaces[0] + + // Namespace exists - would update + result := struct { + DryRun bool + Action string + Namespace string + ResourceVersion string + Spec *namespace.NamespaceSpec + }{ + DryRun: true, + Action: "update", + Namespace: spec.Name, + ResourceVersion: existing.ResourceVersion, + Spec: spec, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) +} diff --git a/temporalcloudcli/commandsgen/code.go b/temporalcloudcli/commandsgen/code.go new file mode 100644 index 0000000..6c67082 --- /dev/null +++ b/temporalcloudcli/commandsgen/code.go @@ -0,0 +1,441 @@ +package commandsgen + +import ( + "bytes" + "fmt" + "go/format" + "path" + "regexp" + "sort" + "strings" + + "go.temporal.io/server/common/primitives/timestamp" +) + +func GenerateCommandsCode(pkg string, commands Commands) ([]byte, error) { + w := &codeWriter{allCommands: commands.CommandList, OptionSets: commands.OptionSets} + // Put terminal check at top + w.writeLinef("var hasHighlighting = %v.IsTerminal(%v.Stdout.Fd())", w.importIsatty(), w.importPkg("os")) + + // Write all option sets + for _, optSet := range commands.OptionSets { + if err := optSet.writeCode(w); err != nil { + return nil, fmt.Errorf("failed writing command %v: %w", optSet.Name, err) + } + } + + // Write all commands, then come back and write package and imports + for _, cmd := range commands.CommandList { + if err := cmd.writeCode(w); err != nil { + return nil, fmt.Errorf("failed writing command %v: %w", cmd.FullName, err) + } + } + + // Write package and imports to final buf + var finalBuf bytes.Buffer + finalBuf.WriteString("// Code generated. DO NOT EDIT.\n\n") + finalBuf.WriteString("package " + pkg + "\n\nimport(\n") + // Sort imports before writing + importLines := make([]string, 0, len(w.imports)) + for _, v := range w.imports { + importLines = append(importLines, fmt.Sprintf("%q\n", v)) + } + sort.Strings(importLines) + for _, v := range importLines { + finalBuf.WriteString(v + "\n") + } + finalBuf.WriteString(")\n\n") + _, _ = finalBuf.ReadFrom(&w.buf) + + // Format and return + b, err := format.Source(finalBuf.Bytes()) + if err != nil { + err = fmt.Errorf("failed generating code: %w, code:\n-----\n%s\n-----", err, finalBuf.Bytes()) + } + return b, err +} + +type codeWriter struct { + buf bytes.Buffer + allCommands []Command + OptionSets []OptionSets + // Key is short ref, value is full + imports map[string]string +} + +var regexNonAlnum = regexp.MustCompile("[^A-Za-z0-9]+") + +func namify(s string, capitalizeFirst bool) string { + // Split on every non-alnum + ret := "" + for i, piece := range regexNonAlnum.Split(s, -1) { + if i > 0 || capitalizeFirst { + piece = strings.ToUpper(piece[:1]) + piece[1:] + } + ret += piece + } + return ret +} + +func (c *codeWriter) writeLinef(s string, args ...any) { + // Ignore errors + _, _ = c.buf.WriteString(fmt.Sprintf(s, args...) + "\n") +} + +func (c *codeWriter) importPkg(pkg string) string { + // For now we'll just panic on dupe and assume last path element is pkg name + ref := strings.TrimPrefix(path.Base(pkg), "go-") + if prev := c.imports[ref]; prev == "" { + if c.imports == nil { + c.imports = make(map[string]string) + } + c.imports[ref] = pkg + } else if prev != pkg { + panic(fmt.Sprintf("duplicate import for %v and %v", pkg, prev)) + } + return ref +} + +func (c *codeWriter) importCobra() string { return c.importPkg("github.com/spf13/cobra") } + +func (c *codeWriter) importPflag() string { return c.importPkg("github.com/spf13/pflag") } + +func (c *codeWriter) importIsatty() string { return c.importPkg("github.com/mattn/go-isatty") } + +func (c *Command) structName() string { return namify(c.FullName, true) + "Command" } + +func (o *OptionSets) writeCode(w *codeWriter) error { + if o.Name == "" { + return fmt.Errorf("missing option set name") + } + + // write struct + w.writeLinef("type %v struct {", o.setStructName()) + for _, opt := range o.Options { + if err := opt.writeStructField(w); err != nil { + return fmt.Errorf("failed writing option set %v: %w", opt.Name, err) + } + + } + w.writeLinef("}\n") + + // write flags + w.writeLinef("func (v *%v) buildFlags(cctx *CommandContext, f *%v.FlagSet) {", + o.setStructName(), w.importPflag()) + o.writeFlagBuilding("v", "f", w) + w.writeLinef("}\n") + + return nil +} + +func (c *Command) writeCode(w *codeWriter) error { + // Find parent command if it exists + var parent Command + var hasParent bool + for _, maybeParent := range w.allCommands { + if c.isSubCommand(&maybeParent) { + parent = maybeParent + hasParent = true + break + } + } + + // Every command is an exposed struct with the cobra command field and each + // flag as a field on the struct + w.writeLinef("type %v struct {", c.structName()) + if hasParent { + w.writeLinef("Parent *%v", parent.structName()) + } + w.writeLinef("Command %v.Command", w.importCobra()) + + // Include option sets + for _, opt := range c.OptionSets { + w.writeLinef("%vOptions", namify(opt, true)) + + } + + // Each option + for _, opt := range c.Options { + if err := opt.writeStructField(w); err != nil { + return fmt.Errorf("failed writing options: %w", err) + } + } + w.writeLinef("}\n") + + // Constructor builds the struct and sets the flags + if hasParent { + w.writeLinef("func New%v(cctx *CommandContext, parent *%v) *%v {", + c.structName(), parent.structName(), c.structName()) + } else { + w.writeLinef("func New%v(cctx *CommandContext) *%v {", c.structName(), c.structName()) + } + w.writeLinef("var s %v", c.structName()) + if hasParent { + w.writeLinef("s.Parent = parent") + } + // Collect subcommands + var subCommands []Command + for _, maybeSubCmd := range w.allCommands { + if maybeSubCmd.isSubCommand(c) { + subCommands = append(subCommands, maybeSubCmd) + } + } + // Set basic command values + if len(subCommands) == 0 { + w.writeLinef("s.Command.DisableFlagsInUseLine = true") + w.writeLinef("s.Command.Use = %q", c.NamePath[len(c.NamePath)-1]+" [flags]") + } else { + w.writeLinef("s.Command.Use = %q", c.NamePath[len(c.NamePath)-1]) + } + w.writeLinef("s.Command.Short = %q", c.Summary) + if c.DescriptionHighlighted != c.DescriptionPlain { + w.writeLinef("if hasHighlighting {") + w.writeLinef("s.Command.Long = %q", c.DescriptionHighlighted) + w.writeLinef("} else {") + w.writeLinef("s.Command.Long = %q", c.DescriptionPlain) + w.writeLinef("}") + } else { + w.writeLinef("s.Command.Long = %q", c.DescriptionPlain) + } + if c.MaximumArgs > 0 { + w.writeLinef("s.Command.Args = %v.MaximumNArgs(%v)", w.importCobra(), c.MaximumArgs) + } else if c.ExactArgs > 0 { + w.writeLinef("s.Command.Args = %v.ExactArgs(%v)", w.importCobra(), c.ExactArgs) + } else { + w.writeLinef("s.Command.Args = %v.NoArgs", w.importCobra()) + } + if c.IgnoreMissingEnv { + w.writeLinef("s.Command.Annotations = make(map[string]string)") + w.writeLinef("s.Command.Annotations[\"ignoresMissingEnv\"] = \"true\"") + } + if c.Deprecated != "" { + w.writeLinef("s.Command.Deprecated = %q", c.Deprecated) + } + // Add subcommands + for _, subCommand := range subCommands { + w.writeLinef("s.Command.AddCommand(&New%v(cctx, &s).Command)", subCommand.structName()) + } + // Set flags + flagVar := "s.Command.Flags()" + if len(subCommands) > 0 { + // If there are subcommands, this needs to be persistent flags + flagVar = "s.Command.PersistentFlags()" + } + var flagAliases [][]string + + for _, opt := range c.Options { + // Add aliases + for _, alias := range opt.Aliases { + flagAliases = append(flagAliases, []string{alias, opt.Name}) + } + + if err := opt.writeFlagBuilding("s", flagVar, w); err != nil { + return fmt.Errorf("failed building option flags: %w", err) + } + } + + for _, include := range c.OptionSets { + // Find include + cmdLoop: + for _, optSet := range w.OptionSets { + if optSet.Name == include { + for _, opt := range optSet.Options { + for _, alias := range opt.Aliases { + flagAliases = append(flagAliases, []string{alias, opt.Name}) + } + } + break cmdLoop + } + + } + + w.writeLinef("s.%v.buildFlags(cctx, %v)", setStructName(include), flagVar) + } + + // Generate normalize for aliases + if len(flagAliases) > 0 { + sort.Slice(flagAliases, func(i, j int) bool { return flagAliases[i][0] < flagAliases[j][0] }) + w.writeLinef("%v.SetNormalizeFunc(aliasNormalizer(map[string]string{", flagVar) + for _, aliases := range flagAliases { + w.writeLinef("%q: %q,", aliases[0], aliases[1]) + } + w.writeLinef("}))") + } + // If there are no subcommands, or if subcommands are optional, we need a run function + if len(subCommands) == 0 || c.SubcommandsOptional { + w.writeLinef("s.Command.Run = func(c *%v.Command, args []string) {", w.importCobra()) + w.writeLinef("if err := s.run(cctx, args); err != nil {") + w.writeLinef("cctx.Options.Fail(err)") + w.writeLinef("}") + w.writeLinef("}") + } + // Init + if c.HasInit { + w.writeLinef("s.initCommand(cctx)") + } + w.writeLinef("return &s") + w.writeLinef("}\n") + return nil +} + +func (o *OptionSets) setStructName() string { return namify(o.Name, true) + "Options" } + +func setStructName(name string) string { return namify(name, true) + "Options" } + +func (o *OptionSets) writeFlagBuilding(selfVar, flagVar string, w *codeWriter) error { + for _, option := range o.Options { + if err := option.writeFlagBuilding(selfVar, flagVar, w); err != nil { + return fmt.Errorf("failed writing flag building for option %v: %w", option.Name, err) + } + } + return nil +} + +func (o *Option) fieldName() string { return namify(o.Name, true) } + +func (o *Option) writeStructField(w *codeWriter) error { + var goDataType string + switch o.Type { + case "bool", "int", "string": + goDataType = o.Type + case "float": + goDataType = "float32" + case "duration": + goDataType = "Duration" + case "timestamp": + goDataType = "Timestamp" + case "string[]": + goDataType = "[]string" + case "string-enum": + goDataType = "StringEnum" + case "string-enum[]": + goDataType = "StringEnumArray" + default: + return fmt.Errorf("unrecognized data type %v", o.Type) + } + w.writeLinef("%v %v", o.fieldName(), goDataType) + return nil +} + +func (o *Option) writeFlagBuilding(selfVar, flagVar string, w *codeWriter) error { + var flagMeth, defaultLit, setDefault string + switch o.Type { + case "bool": + flagMeth, defaultLit = "BoolVar", ", false" + if o.Default != "" { + return fmt.Errorf("cannot have default for bool var") + } + case "duration": + flagMeth, setDefault = "Var", "0" + if o.Default != "" { + dur, err := timestamp.ParseDuration(o.Default) + if err != nil { + return fmt.Errorf("invalid default: %w", err) + } + // We round to the nearest ms + setDefault = fmt.Sprintf("Duration(%v * %v.Millisecond)", dur.Milliseconds(), w.importPkg("time")) + } + case "timestamp": + if o.Default != "" { + return fmt.Errorf("default value not allowed for timestamp") + } + flagMeth, defaultLit = "Var", "" + case "int": + flagMeth, defaultLit = "IntVar", ", "+o.Default + if o.Default == "" { + defaultLit = ", 0" + } + case "float": + flagMeth, defaultLit = "Float32Var", ", "+o.Default + if o.Default == "" { + defaultLit = ", 0" + } + case "string": + flagMeth, defaultLit = "StringVar", fmt.Sprintf(", %q", o.Default) + case "string[]": + if o.Default != "" { + return fmt.Errorf("default value not allowed for string array") + } + flagMeth, defaultLit = "StringArrayVar", ", nil" + case "string-enum": + if len(o.EnumValues) == 0 { + return fmt.Errorf("missing enum values") + } + // Create enum + pieces := make([]string, len(o.EnumValues)+len(o.HiddenLegacyValues)) + for i, enumVal := range o.EnumValues { + pieces[i] = fmt.Sprintf("%q", enumVal) + } + for i, legacyVal := range o.HiddenLegacyValues { + pieces[i+len(o.EnumValues)] = fmt.Sprintf("%q", legacyVal) + } + + w.writeLinef("%v.%v = NewStringEnum([]string{%v}, %q)", + selfVar, o.fieldName(), strings.Join(pieces, ", "), o.Default) + flagMeth = "Var" + case "string-enum[]": + if len(o.EnumValues) == 0 { + return fmt.Errorf("missing enum values") + } + // Create enum + pieces := make([]string, len(o.EnumValues)+len(o.HiddenLegacyValues)) + for i, enumVal := range o.EnumValues { + pieces[i] = fmt.Sprintf("%q", enumVal) + } + for i, legacyVal := range o.HiddenLegacyValues { + pieces[i+len(o.EnumValues)] = fmt.Sprintf("%q", legacyVal) + } + + if o.Default != "" { + w.writeLinef("%v.%v = NewStringEnumArray([]string{%v}, %q)", + selfVar, o.fieldName(), strings.Join(pieces, ", "), o.Default) + } else { + w.writeLinef("%v.%v = NewStringEnumArray([]string{%v}, []string{})", + selfVar, o.fieldName(), strings.Join(pieces, ", ")) + } + flagMeth = "Var" + default: + return fmt.Errorf("unrecognized data type %v", o.Type) + } + + // If there are enums, append to desc + desc := o.Description + if len(o.EnumValues) > 0 { + desc += fmt.Sprintf(" Accepted values: %s.", strings.Join(o.EnumValues, ", ")) + } + // If required, append to desc + if o.Required { + desc += " Required." + } + // If there are aliases, append to desc + for _, alias := range o.Aliases { + desc += fmt.Sprintf(` Aliased as "--%v".`, alias) + } + // If experimental, make obvious + if o.Experimental { + desc += " EXPERIMENTAL." + } + + if setDefault != "" { + // set default before calling Var so that it stores thedefault value into the flag + w.writeLinef("%v.%v = %v", selfVar, o.fieldName(), setDefault) + } + if o.Short != "" { + w.writeLinef("%v.%vP(&%v.%v, %q, %q%v, %q)", flagVar, flagMeth, selfVar, o.fieldName(), o.Name, o.Short, defaultLit, desc) + } else { + w.writeLinef("%v.%v(&%v.%v, %q%v, %q)", flagVar, flagMeth, selfVar, o.fieldName(), o.Name, defaultLit, desc) + } + if o.DisplayType != "" { + w.writeLinef("overrideFlagDisplayType(%v.Lookup(%q), %q)", flagVar, o.Name, o.DisplayType) + } + if o.Required { + w.writeLinef("_ = %v.MarkFlagRequired(%v, %q)", w.importCobra(), flagVar, o.Name) + } + if o.Env != "" { + w.writeLinef("cctx.BindFlagEnvVar(%v.Lookup(%q), %q)", flagVar, o.Name, o.Env) + } + if o.Deprecated != "" { + w.writeLinef("_ = %v.MarkDeprecated(%q, %q)", flagVar, o.Name, o.Deprecated) + } + return nil +} diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml new file mode 100644 index 0000000..55b70ce --- /dev/null +++ b/temporalcloudcli/commandsgen/commands.yml @@ -0,0 +1,230 @@ +# Temporal Cloud CLI commands +commands: + - name: cloud + summary: Temporal Cloud command-line interface + description: | + The Temporal Cloud CLI provides management and operations for Temporal Cloud. + + Example: + + ``` + cloud namespace + ``` + has-init: true + options: + - name: config-file + type: string + description: | + File path to read TOML config from, defaults to + `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is defined + as `$HOME/.config` on Unix, "$HOME/Library/Application Support" on + macOS, and %AppData% on Windows. + experimental: true + implied-env: TEMPORAL_CONFIG_FILE + - name: profile + type: string + description: Profile to use for config file. + experimental: true + implied-env: TEMPORAL_PROFILE + - name: disable-config-file + type: bool + description: | + If set, disables loading environment config from config file. + experimental: true + - name: disable-config-env + type: bool + description: | + If set, disables loading environment config from environment + variables. + experimental: true + - name: log-level + type: string-enum + enum-values: + - debug + - info + - warn + - error + - never + description: | + Log level. + Default is "info" for most commands and "warn" for `server start-dev`. + default: info + - name: log-format + type: string-enum + description: Log format. + enum-values: + - text + - json + hidden-legacy-values: + - pretty + default: text + - name: output + type: string-enum + short: o + description: Non-logging data output format. + enum-values: + - text + - json + - jsonl + - none + default: text + - name: time-format + type: string-enum + description: Time format. + enum-values: + - relative + - iso + - raw + default: relative + - name: color + type: string-enum + description: Output coloring. + enum-values: + - always + - never + - auto + default: auto + - name: no-json-shorthand-payloads + type: bool + description: Raw payload output, even if the JSON option was used. + - name: command-timeout + type: duration + description: | + The command execution timeout. 0s means no timeout. + - name: client-connect-timeout + type: duration + description: | + The client connection timeout. 0s means no timeout. + - name: domain + type: string + description: The domain to log into. + default: login.tmprl.cloud + - name: audience + type: string + default: https://saas-api.tmprl.cloud + description: Used for login. + - name: client-id + type: string + description: Used for login. + - name: config-dir + type: string + description: The directory to store the config into. + - name: disable-pop-up + type: bool + description: Disable browser pop-up. + - name: api-key + type: string + description: The api key to use for auth. + - name: cloud login + summary: Log into temporal cloud + description: | + Log into temporal cloud. + has-init: false + docs: + keywords: + - login + description-header: Login + tags: + - login + - name: cloud namespace + summary: Manage namespaces + description: | + Commands for managing namespaces. + has-init: false + docs: + keywords: + - namespace + - management + description-header: Namespace Management Commands + tags: + - namespaces + - name: cloud namespace get + summary: Get a namespace + description: | + Get a namespace from temporal cloud. + has-init: false + docs: + keywords: + - namespace + - management + description-header: Retrieve a namespace. + tags: + - namespaces + options: + - name: namespace + required: true + type: string + description: The namespace to get, including the account. For example my-namespace.my-account. + short: n + - name: cloud namespace apply + summary: Create or update a namespace + description: | + Apply a namespace configuration to Temporal Cloud. This command creates a + new namespace or updates an existing one based on the provided specification. + + You can specify the namespace configuration using a JSON specification, + provided either inline or as a file path. + + Example with inline JSON: + + ``` + cloud namespace apply --spec '{"name": "namespace-name", "region": "us-west-2", "retention_days": 7}' + ``` + + Example with file path: + + ``` + cloud namespace apply --spec @namespace-spec.json + ``` + has-init: false + options: + - name: spec + type: string + description: JSON specification for the namespace configuration. Can be provided as inline JSON or as a file path. If the value starts with '@', it will be treated as a file path (e.g., '@config.json'). + short: s + required: true + - name: dry-run + type: bool + description: Validate the configuration without applying changes. Shows what would be created or updated. + - name: async-operation-id + type: string + short: a + description: The async operation id to use for the request, optional. + - name: idemptotent + type: bool + short: i + description: Determines whether the command should error if there's nothing that has changed. + - name: async + type: bool + short: c + description: Determines whether the command should return immediately with the async operation or wait until it completes. + - name: cloud namespace edit + summary: Edit a namespace + description: | + Edit a namespace spec on Temporal Cloud. This command updates a namespace with changes + specified by the user in an edit operation. + + Example: + + ``` + cloud namespace edit --namespace my-namespace.my-account + ``` + has-init: false + options: + - name: namespace + type: string + description: The namespace to get, including the account. For example my-namespace.my-account. + short: n + required: true + - name: async-operation-id + type: string + short: a + description: The async operation id to use for the request, optional. + - name: idemptotent + type: bool + short: i + description: Determines whether the command should error if there's nothing that has changed. + - name: async + type: bool + short: c + description: Determines whether the command should return immediately with the async operation or wait until it completes. diff --git a/temporalcloudcli/commandsgen/docs.go b/temporalcloudcli/commandsgen/docs.go new file mode 100644 index 0000000..2cd912a --- /dev/null +++ b/temporalcloudcli/commandsgen/docs.go @@ -0,0 +1,192 @@ +package commandsgen + +import ( + "bytes" + "fmt" + "regexp" + "sort" + "strings" +) + +func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { + optionSetMap := make(map[string]OptionSets) + for i, optionSet := range commands.OptionSets { + optionSetMap[optionSet.Name] = commands.OptionSets[i] + } + + w := &docWriter{ + fileMap: make(map[string]*bytes.Buffer), + optionSetMap: optionSetMap, + allCommands: commands.CommandList, + } + + // sorted ascending by full name of command (activity complete, batch list, etc) + for _, cmd := range commands.CommandList { + if err := cmd.writeDoc(w); err != nil { + return nil, fmt.Errorf("failed writing docs for command %s: %w", cmd.FullName, err) + } + } + + // Format and return + var finalMap = make(map[string][]byte) + for key, buf := range w.fileMap { + finalMap[key] = buf.Bytes() + } + return finalMap, nil +} + +type docWriter struct { + allCommands []Command + fileMap map[string]*bytes.Buffer + optionSetMap map[string]OptionSets + optionsStack [][]Option +} + +func (c *Command) writeDoc(w *docWriter) error { + w.processOptions(c) + + // If this is a root command, write a new file + depth := c.depth() + if depth == 1 { + w.writeCommand(c) + } else if depth > 1 { + w.writeSubcommand(c) + } + return nil +} + +func (w *docWriter) writeCommand(c *Command) { + fileName := c.fileName() + w.fileMap[fileName] = &bytes.Buffer{} + w.fileMap[fileName].WriteString("---\n") + w.fileMap[fileName].WriteString("id: " + fileName + "\n") + w.fileMap[fileName].WriteString("title: Temporal CLI " + fileName + " command reference\n") + w.fileMap[fileName].WriteString("sidebar_label: " + fileName + "\n") + w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n") + w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") + + w.fileMap[fileName].WriteString("keywords:\n") + for _, keyword := range c.Docs.Keywords { + w.fileMap[fileName].WriteString(" - " + keyword + "\n") + } + w.fileMap[fileName].WriteString("tags:\n") + for _, tag := range c.Docs.Tags { + w.fileMap[fileName].WriteString(" - " + tag + "\n") + } + w.fileMap[fileName].WriteString("---") + w.fileMap[fileName].WriteString("\n\n") + w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n") + w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli/blob/main/temporalcli/commandsgen/commands.yml */}\n") +} + +func (w *docWriter) writeSubcommand(c *Command) { + fileName := c.fileName() + prefix := strings.Repeat("#", c.depth()) + w.fileMap[fileName].WriteString(prefix + " " + c.leafName() + "\n\n") + w.fileMap[fileName].WriteString(c.Description + "\n\n") + + if w.isLeafCommand(c) { + w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command.\n\n") + + // gather options from command and all options aviailable from parent commands + var options = make([]Option, 0) + var globalOptions = make([]Option, 0) + for i, o := range w.optionsStack { + if i == len(w.optionsStack)-1 { + options = append(options, o...) + } else { + globalOptions = append(globalOptions, o...) + } + } + + // alphabetize options + sort.Slice(options, func(i, j int) bool { + return options[i].Name < options[j].Name + }) + + sort.Slice(globalOptions, func(i, j int) bool { + return globalOptions[i].Name < globalOptions[j].Name + }) + + w.writeOptions("Flags", options, c) + w.writeOptions("Global Flags", globalOptions, c) + + } +} + +func (w *docWriter) writeOptions(prefix string, options []Option, c *Command) { + if len(options) == 0 { + return + } + + fileName := c.fileName() + + w.fileMap[fileName].WriteString(fmt.Sprintf("**%s:**\n\n", prefix)) + + for _, o := range options { + // option name and alias + w.fileMap[fileName].WriteString(fmt.Sprintf("**--%s**", o.Name)) + if len(o.Short) > 0 { + w.fileMap[fileName].WriteString(fmt.Sprintf(", **-%s**", o.Short)) + } + w.fileMap[fileName].WriteString(fmt.Sprintf(" _%s_\n\n", o.Type)) + + // description + w.fileMap[fileName].WriteString(encodeJSONExample(o.Description)) + if o.Required { + w.fileMap[fileName].WriteString(" Required.") + } + if len(o.EnumValues) > 0 { + w.fileMap[fileName].WriteString(fmt.Sprintf(" Accepted values: %s.", strings.Join(o.EnumValues, ", "))) + } + if len(o.Default) > 0 { + w.fileMap[fileName].WriteString(fmt.Sprintf(` (default "%s")`, o.Default)) + } + w.fileMap[fileName].WriteString("\n\n") + + if o.Experimental { + w.fileMap[fileName].WriteString(":::note" + "\n\n") + w.fileMap[fileName].WriteString("Option is experimental." + "\n\n") + w.fileMap[fileName].WriteString(":::" + "\n\n") + } + } +} + +func (w *docWriter) processOptions(c *Command) { + // Pop options from stack if we are moving up a level + if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { + w.optionsStack = w.optionsStack[:len(w.optionsStack)-1] + } + var options []Option + options = append(options, c.Options...) + + // Maintain stack of options available from parent commands + for _, set := range c.OptionSets { + optionSet, ok := w.optionSetMap[set] + if !ok { + panic(fmt.Sprintf("invalid option set %v used", set)) + } + optionSetOptions := optionSet.Options + options = append(options, optionSetOptions...) + } + + w.optionsStack = append(w.optionsStack, options) +} + +func (w *docWriter) isLeafCommand(c *Command) bool { + for _, maybeSubCmd := range w.allCommands { + if maybeSubCmd.isSubCommand(c) { + return false + } + } + return true +} + +func encodeJSONExample(v string) string { + // example: 'YourKey={"your": "value"}' + // results in an mdx acorn rendering error + // and wrapping in backticks lets it render + re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) + v = re.ReplaceAllString(v, "`$1`") + return v +} diff --git a/temporalcloudcli/commandsgen/parse.go b/temporalcloudcli/commandsgen/parse.go new file mode 100644 index 0000000..7945e1c --- /dev/null +++ b/temporalcloudcli/commandsgen/parse.go @@ -0,0 +1,249 @@ +// Package commandsgen is built to read the YAML format described in +// temporalcli/commandsgen/commands.yml and generate code from it. +package commandsgen + +import ( + "bytes" + _ "embed" + "fmt" + "regexp" + "slices" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed commands.yml +var CommandsYAML []byte + +type ( + // Option represents the structure of an option within option sets. + Option struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + DisplayType string `yaml:"display-type"` + Description string `yaml:"description"` + Deprecated string `yaml:"deprecated"` + Short string `yaml:"short,omitempty"` + Default string `yaml:"default,omitempty"` + Env string `yaml:"env,omitempty"` + ImpliedEnv string `yaml:"implied-env,omitempty"` + Required bool `yaml:"required,omitempty"` + Aliases []string `yaml:"aliases,omitempty"` + EnumValues []string `yaml:"enum-values,omitempty"` + Experimental bool `yaml:"experimental,omitempty"` + HiddenLegacyValues []string `yaml:"hidden-legacy-values,omitempty"` + } + + // Command represents the structure of each command in the commands map. + Command struct { + FullName string `yaml:"name"` + NamePath []string + Summary string `yaml:"summary"` + Description string `yaml:"description"` + DescriptionPlain string + DescriptionHighlighted string + Deprecated string `yaml:"deprecated"` + HasInit bool `yaml:"has-init"` + ExactArgs int `yaml:"exact-args"` + MaximumArgs int `yaml:"maximum-args"` + IgnoreMissingEnv bool `yaml:"ignores-missing-env"` + SubcommandsOptional bool `yaml:"subcommands-optional"` + Options []Option `yaml:"options"` + OptionSets []string `yaml:"option-sets"` + Docs Docs `yaml:"docs"` + } + + // Docs represents docs-only information that is not used in CLI generation. + Docs struct { + Keywords []string `yaml:"keywords"` + DescriptionHeader string `yaml:"description-header"` + Tags []string `yaml:"tags"` + } + + // OptionSets represents the structure of option sets. + OptionSets struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Options []Option `yaml:"options"` + } + + // Commands represents the top-level structure holding commands and option sets. + Commands struct { + CommandList []Command `yaml:"commands"` + OptionSets []OptionSets `yaml:"option-sets"` + } +) + +func ParseCommands() (Commands, error) { + // Fix CRLF + md := bytes.ReplaceAll(CommandsYAML, []byte("\r\n"), []byte("\n")) + + var m Commands + err := yaml.Unmarshal(md, &m) + if err != nil { + return Commands{}, fmt.Errorf("failed unmarshalling yaml: %w", err) + } + + for i, optionSet := range m.OptionSets { + if err := m.OptionSets[i].processSection(); err != nil { + return Commands{}, fmt.Errorf("failed parsing option set section %q: %w", optionSet.Name, err) + } + } + + for i, command := range m.CommandList { + if err := m.CommandList[i].processSection(); err != nil { + return Commands{}, fmt.Errorf("failed parsing command section %q: %w", command.FullName, err) + } + } + + // alphabetize commands + sort.Slice(m.CommandList, func(i, j int) bool { + return m.CommandList[i].FullName < m.CommandList[j].FullName + }) + + return m, nil +} + +var markdownLinkPattern = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`) +var markdownBlockCodeRegex = regexp.MustCompile("```([\\s\\S]+?)```") +var markdownInlineCodeRegex = regexp.MustCompile("`([^`]+)`") + +const ansiReset = "\033[0m" +const ansiBold = "\033[1m" + +func (o OptionSets) processSection() error { + if o.Name == "" { + return fmt.Errorf("missing option set name") + } + + for i, option := range o.Options { + if err := o.Options[i].processSection(); err != nil { + return fmt.Errorf("failed parsing option '%v': %w", option.Name, err) + } + } + + return nil +} + +func (c *Command) processSection() error { + if c.FullName == "" { + return fmt.Errorf("missing command name") + } + c.NamePath = strings.Split(c.FullName, " ") + + if c.Summary == "" { + return fmt.Errorf("missing summary for command") + } + if c.Summary[len(c.Summary)-1] == '.' { + return fmt.Errorf("summary should not end in a '.'") + } + + if c.MaximumArgs != 0 && c.ExactArgs != 0 { + return fmt.Errorf("cannot have both maximum-args and exact-args") + } + + if c.Description == "" { + return fmt.Errorf("missing description for command: %s", c.FullName) + } + + if len(c.NamePath) == 2 { + if c.Docs.Keywords == nil { + return fmt.Errorf("missing keywords for root command: %s", c.FullName) + } + if c.Docs.DescriptionHeader == "" { + return fmt.Errorf("missing description for root command: %s", c.FullName) + } + if len(c.Docs.Tags) == 0 { + return fmt.Errorf("missing tags for root command: %s", c.FullName) + } + } + + // Strip trailing newline for description + c.Description = strings.TrimRight(c.Description, "\n") + + // Strip links for long plain/highlighted + c.DescriptionPlain = markdownLinkPattern.ReplaceAllString(c.Description, "$1") + c.DescriptionHighlighted = c.DescriptionPlain + + // Highlight code for long highlighted + c.DescriptionHighlighted = markdownBlockCodeRegex.ReplaceAllStringFunc(c.DescriptionHighlighted, func(s string) string { + s = strings.Trim(s, "`") + s = strings.Trim(s, " ") + s = strings.Trim(s, "\n") + return ansiBold + s + ansiReset + }) + c.DescriptionHighlighted = markdownInlineCodeRegex.ReplaceAllStringFunc(c.DescriptionHighlighted, func(s string) string { + s = strings.Trim(s, "`") + return ansiBold + s + ansiReset + }) + + // Each option + for i, option := range c.Options { + if err := c.Options[i].processSection(); err != nil { + return fmt.Errorf("failed parsing option '%v': %w", option.Name, err) + } + } + + return nil +} + +func (c *Command) isSubCommand(maybeParent *Command) bool { + return len(c.NamePath) == len(maybeParent.NamePath)+1 && strings.HasPrefix(c.FullName, maybeParent.FullName+" ") +} + +func (c *Command) leafName() string { + return strings.Join(strings.Split(c.FullName, " ")[c.depth():], "") +} + +func (c *Command) fileName() string { + if c.depth() <= 0 { + return "" + } + return strings.Split(c.FullName, " ")[1] +} + +func (c *Command) depth() int { + return len(strings.Split(c.FullName, " ")) - 1 +} + +func (o *Option) processSection() error { + if o.Name == "" { + return fmt.Errorf("missing option name") + } + + if o.Type == "" { + return fmt.Errorf("missing option type") + } + if o.Type != "string" && o.DisplayType != "" { + return fmt.Errorf("display-type is only allowed for string options") + } + + if o.Description == "" { + return fmt.Errorf("missing description for option: %s", o.Name) + } + // Strip all newline for description and trailing whitespace + o.Description = strings.ReplaceAll(o.Description, "\n", " ") + o.Description = strings.TrimRight(o.Description, " ") + + // Check that description ends in a "." + if o.Description[len(o.Description)-1] != '.' { + return fmt.Errorf("description should end in a '.'") + } + + if o.Env != strings.ToUpper(o.Env) { + return fmt.Errorf("env variables must be in all caps") + } + + if len(o.EnumValues) != 0 { + if o.Type != "string-enum" && o.Type != "string-enum[]" { + return fmt.Errorf("enum-values can only specified for string-enum and string-enum[] types") + } + // Check default enum values + if o.Default != "" && !slices.Contains(o.EnumValues, o.Default) { + return fmt.Errorf("default value '%s' must be one of the enum-values options %s", o.Default, o.EnumValues) + } + } + return nil +} diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go new file mode 100644 index 0000000..ee2facc --- /dev/null +++ b/temporalcloudcli/common.go @@ -0,0 +1,174 @@ +package temporalcloudcli + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func isNothingChangedErr(idempotent bool, e error) bool { + // If we are not idempotent, we should error on nothing to change + if !idempotent { + return false + } + + s, ok := status.FromError(e) + if !ok { + return false + } + return s.Code() == codes.InvalidArgument && strings.Contains(s.Message(), "nothing to change") +} + +// loadJSONSpec loads a JSON specification from either a file path (prefixed with '@') +// or treats the input as inline JSON. Returns the parsed data as a byte slice. +func loadJSONSpec(spec string) ([]byte, error) { + // Check if spec starts with '@' indicating file path + if strings.HasPrefix(spec, "@") { + // Remove '@' prefix and read file + filePath := strings.TrimPrefix(spec, "@") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read spec file %q: %w", filePath, err) + } + return data, nil + } + + // Treat as inline JSON + return []byte(spec), nil +} + +func runEditorForJSONEdit(existing, valuePtr any) error { + existingBytes, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return fmt.Errorf("unable to convert existing object to json: %v", err) + } + updatedBytes, err := runEditor(existingBytes) + if err != nil { + return err + } + return json.Unmarshal(updatedBytes, valuePtr) +} + +func runEditorForJSONEditForProtos(existing, value proto.Message) error { + marshaler := protojson.MarshalOptions{ + EmitUnpopulated: true, + Indent: " ", + } + existingBytes, err := marshaler.Marshal(existing) + if err != nil { + return fmt.Errorf("unable to convert existing object to json: %v", err) + } + updatedBytes, err := runEditor(existingBytes) + if err != nil { + return err + } + unmarshaller := protojson.UnmarshalOptions{} + return unmarshaller.Unmarshal(updatedBytes, value) +} + +func runEditor(existing []byte) ([]byte, error) { + f, err := os.CreateTemp("", "cloud-cli-edit-*.json") + if err != nil { + return nil, fmt.Errorf("unable to create temp file for editing: %v", err) + } + + if _, err := f.Write(existing); err != nil { + return nil, fmt.Errorf("unable to write existing data to temp file for editing: %v", err) + } + + if err := f.Close(); err != nil { + return nil, fmt.Errorf("unable to close temp file: %v", err) + } + + editor := strings.Split(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vim"), " ") + program, args := editor[0], editor[1:] + + cmd := exec.Command(program, append(args, f.Name())...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("error executing %q: %v", editor, err) + } + + updated, err := os.ReadFile(f.Name()) + if err != nil { + return nil, fmt.Errorf("unable to read updated data from temp file: %v", err) + } + + if bytes.Equal(existing, updated) { + return nil, fmt.Errorf("no changes detected") + } + return updated, nil +} + +// pollAsyncOperation polls an async operation until it reaches a terminal state. +// It prints status updates every second and returns the final AsyncOperation. +func pollAsyncOperation( + cctx *CommandContext, + operationID string, +) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-cctx.Context.Done(): + return fmt.Errorf("operation polling cancelled: %w", cctx.Context.Err()) + case <-ticker.C: + // Get the current state of the operation + resp, err := cloudClient.CloudService().GetAsyncOperation(cctx.Context, &cloudservice.GetAsyncOperationRequest{ + AsyncOperationId: operationID, + }) + if err != nil { + return fmt.Errorf("failed to get async operation status: %w", err) + } + + asyncOp := resp.GetAsyncOperation() + if asyncOp == nil { + return fmt.Errorf("async operation not found") + } + + // Print current state + switch asyncOp.State { + case operation.AsyncOperation_STATE_PENDING: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_IN_PROGRESS: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation in progress...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_FULFILLED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_FAILED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_CANCELLED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation cancelled\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_REJECTED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation rejected\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + default: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + } + } + } +} diff --git a/temporalcloudcli/duration.go b/temporalcloudcli/duration.go new file mode 100644 index 0000000..ea00fcb --- /dev/null +++ b/temporalcloudcli/duration.go @@ -0,0 +1,30 @@ +package temporalcloudcli + +import ( + "time" + + "go.temporal.io/server/common/primitives/timestamp" +) + +type Duration time.Duration + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} + +func (d *Duration) String() string { + return d.Duration().String() +} + +func (d *Duration) Set(s string) error { + p, err := timestamp.ParseDuration(s) + if err != nil { + return err + } + *d = Duration(p) + return nil +} + +func (d *Duration) Type() string { + return "duration" +} diff --git a/temporalcloudcli/internal/cmd/gen-commands/main.go b/temporalcloudcli/internal/cmd/gen-commands/main.go new file mode 100644 index 0000000..a5ca006 --- /dev/null +++ b/temporalcloudcli/internal/cmd/gen-commands/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/temporalio/cloud-cli/temporalcloudcli/commandsgen" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + // Get commands dir + _, file, _, _ := runtime.Caller(0) + commandsDir := filepath.Join(file, "../../../../") + + // Parse YAML + cmds, err := commandsgen.ParseCommands() + if err != nil { + return fmt.Errorf("failed parsing markdown: %w", err) + } + + // Generate code + b, err := commandsgen.GenerateCommandsCode("temporalcloudcli", cmds) + if err != nil { + return fmt.Errorf("failed generating code: %w", err) + } + + // Write + if err := os.WriteFile(filepath.Join(commandsDir, "commands.gen.go"), b, 0644); err != nil { + return fmt.Errorf("failed writing file: %w", err) + } + return nil +} diff --git a/temporalcloudcli/internal/cmd/gen-docs/main.go b/temporalcloudcli/internal/cmd/gen-docs/main.go new file mode 100644 index 0000000..94ff20b --- /dev/null +++ b/temporalcloudcli/internal/cmd/gen-docs/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/temporalio/cli/temporalcli/commandsgen" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + // Get commands dir + _, file, _, _ := runtime.Caller(0) + docsDir := filepath.Join(file, "../../../../docs/") + + err := os.MkdirAll(docsDir, os.ModePerm) + if err != nil { + log.Fatalf("Error creating directory: %v", err) + } + + // Parse markdown + cmds, err := commandsgen.ParseCommands() + if err != nil { + return fmt.Errorf("failed parsing markdown: %w", err) + } + + // Generate docs + b, err := commandsgen.GenerateDocsFiles(cmds) + if err != nil { + return err + } + + // Write + for filename, content := range b { + filePath := filepath.Join(docsDir, filename+".mdx") + if err := os.WriteFile(filePath, content, 0644); err != nil { + return fmt.Errorf("failed writing file: %w", err) + } + } + + return nil +} diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go new file mode 100644 index 0000000..87d2713 --- /dev/null +++ b/temporalcloudcli/internal/printer/printer.go @@ -0,0 +1,611 @@ +package printer + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "reflect" + "slices" + "strconv" + "strings" + "time" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "go.temporal.io/api/common/v1" + "go.temporal.io/api/temporalproto" + "google.golang.org/protobuf/proto" +) + +const NonJSONIndent = " " + +type Colorer func(string, ...interface{}) string + +type Printer struct { + // Must always be present + Output io.Writer + JSON bool + // This is unset/empty in JSONL mode + JSONIndent string + JSONPayloadShorthand bool + // Only used for non-JSON, defaults to RFC3339 + FormatTime func(time.Time) string + // Only used for non-JSON, defaults to color.Magenta + TableHeaderColorer Colorer + + listMode bool + listModeFirstJSON bool // True until first JSON printed +} + +// Ignored during JSON output +func (p *Printer) Print(s ...string) { + if !p.JSON { + for _, v := range s { + p.writeStr(v) + } + } +} + +// Ignored during JSON output +func (p *Printer) Println(s ...string) { + p.Print(append(append([]string{}, s...), "\n")...) +} + +// Ignored during JSON output +func (p *Printer) Printlnf(s string, v ...any) { + p.Println(fmt.Sprintf(s, v...)) +} + +// When called for JSON with indent, this will create an initial bracket and +// make sure all [Printer.PrintStructured] calls get commas properly to appear +// as a list (but the indention and multiline posture of the JSON remains). When +// called for JSON without indent, this will make sure all +// [Printer.PrintStructured] is on its own line (i.e. JSONL mode). When called +// for non-JSON, this is a no-op. +// +// [Printer.EndList] must be called at the end. If this is called twice it will +// panic. This and the end call are not safe for concurrent use. +func (p *Printer) StartList() { + if p.listMode { + panic("already in list mode") + } + p.listMode, p.listModeFirstJSON = true, true + // Write initial bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // Don't need newline, we count on initial object to do that + p.Output.Write([]byte("[")) + } +} + +// Must be called after [Printer.StartList] or will panic. See Godoc on that +// function for more details. +func (p *Printer) EndList() { + if !p.listMode { + panic("not in list mode") + } + p.listMode, p.listModeFirstJSON = false, false + // Write ending bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // We prepend a newline because non-jsonl list mode doesn't do so after each + // line to help with commas + p.Output.Write([]byte("\n]\n")) + } +} + +type StructuredOptions struct { + // Derived if not present. Ignored for JSON printing. + Fields []string + // Ignored for JSON printing. + ExcludeFields []string + // If not set, not printed as table in text mode. This is ignored for JSON + // printing. + Table *TableOptions + OverrideJSONPayloadShorthand *bool + // Indent this many additional times when printing non-JSON + NonJSONExtraIndent int +} + +type Align int + +const ( + AlignDefault Align = tablewriter.ALIGN_DEFAULT + AlignCenter = tablewriter.ALIGN_CENTER + AlignRight = tablewriter.ALIGN_RIGHT + AlignLeft = tablewriter.ALIGN_LEFT +) + +type TableOptions struct { + // If not set for a field, maximum width of all rows for structured, and no + // width for streaming table. Field width will always at least be field name. + FieldWidths map[string]int + // Fields are align-left by default + FieldAlign map[string]Align + NoHeader bool +} + +// For JSON, if v is a proto message, protojson encoding is used +func (p *Printer) PrintStructured(v any, options StructuredOptions) error { + // JSON + if p.JSON { + return p.printJSON(v, options) + } + + // Get data + cols := options.toPredefinedCols() + cols, rows, err := p.tableData(cols, v) + if err != nil { + return err + } + cols = adjustColsToOptions(cols, options) + + // Text table + if options.Table != nil { + p.calculateUnsetColWidths(cols, rows) + p.printTable(options.Table, cols, rows) + return nil + } + + // Text "card" + p.printCards(cols, rows) + return nil +} + +type PrintStructuredIter interface { + // Nil when done + Next() (any, error) +} + +// Fields must be present for table +func (p *Printer) PrintStructuredTableIter( + typ reflect.Type, + iter PrintStructuredIter, + options StructuredOptions, +) error { + if options.Table == nil { + return fmt.Errorf("must be table") + } + cols := options.toPredefinedCols() + if len(cols) == 0 { + var err error + if cols, err = deriveCols(typ); err != nil { + return fmt.Errorf("unable to derive columns: %w", err) + } + } + cols = adjustColsToOptions(cols, options) + // We're intentionally not calculating field lengths and only accepting them + // since this is streaming + p.printHeader(cols) + for { + v, err := iter.Next() + if v == nil || err != nil { + return err + } + row, err := p.tableRowData(cols, v) + if err != nil { + return err + } + p.printRow(cols, row) + } +} + +func (p *Printer) write(b []byte) { + if _, err := p.Output.Write(b); err != nil { + panic(err) + } +} + +func (p *Printer) writeStr(s string) { + p.write([]byte(s)) +} + +func (p *Printer) writef(s string, v ...any) { + if _, err := fmt.Fprintf(p.Output, s, v...); err != nil { + panic(err) + } +} + +func (p *Printer) printJSON(v any, options StructuredOptions) error { + // Before printing, if we're in non-jsonl list mode, we must append a comma + // and a newline if we're not the first JSON seen. + nonJSONLListMode := p.listMode && p.JSON && p.JSONIndent != "" + if nonJSONLListMode { + var prepend string + if p.listModeFirstJSON { + p.listModeFirstJSON = false + prepend = "\n" + } else { + prepend = ",\n" + } + if _, err := p.Output.Write([]byte(prepend)); err != nil { + return err + } + } + + // Print JSON + shorthandPayloads := p.JSONPayloadShorthand + if options.OverrideJSONPayloadShorthand != nil { + shorthandPayloads = *options.OverrideJSONPayloadShorthand + } + if b, err := p.jsonVal(v, p.JSONIndent, shorthandPayloads); err != nil { + return err + } else if _, err := p.Output.Write(b); err != nil { + return err + } + + // Do not print a newline if in non-jsonl list mode + if !nonJSONLListMode { + if _, err := p.Output.Write([]byte("\n")); err != nil { + return err + } + } + return nil +} + +func (p *Printer) jsonVal(v any, indent string, shorthandPayloads bool) ([]byte, error) { + // Use proto JSON if a proto message + if protoMessage, ok := v.(proto.Message); ok { + opts := temporalproto.CustomJSONMarshalOptions{Indent: indent} + if shorthandPayloads { + opts.Metadata = map[string]any{common.EnablePayloadShorthandMetadataKey: true} + } + return opts.Marshal(protoMessage) + } + + // Normal JSON encoding + if indent != "" { + return json.MarshalIndent(v, "", indent) + } + return json.Marshal(v) +} + +type col struct { + name string + // 0 means no padding + width int + cardOmitEmpty bool + align Align + indentAmount int +} + +type colVal struct { + val any + text string +} + +// This is just based on name, expects call to adjustColsToOptions to properly +// apply details +func (s *StructuredOptions) toPredefinedCols() []*col { + if len(s.Fields) == 0 { + return nil + } + cols := make([]*col, 0, len(s.Fields)) + for _, field := range s.Fields { + if !slices.Contains(s.ExcludeFields, field) { + cols = append(cols, &col{name: field}) + } + } + return cols +} + +func (p *Printer) calculateUnsetColWidths(cols []*col, rows []map[string]colVal) { + for _, col := range cols { + // Ignore if already set + if col.width > 0 { + continue + } + // Must be at least the name length + col.width = tablewriter.DisplayWidth(col.name) + // Now check every col val + for _, row := range rows { + if colLen := tablewriter.DisplayWidth(row[col.name].text); colLen > col.width { + col.width = colLen + } + } + } +} + +func adjustColsToOptions(cols []*col, options StructuredOptions) []*col { + adjusted := make([]*col, 0, len(cols)) + for _, col := range cols { + if slices.Contains(options.ExcludeFields, col.name) { + continue + } + if options.Table != nil { + if width := options.Table.FieldWidths[col.name]; width > 0 { + col.width = width + } + if align, ok := options.Table.FieldAlign[col.name]; ok { + col.align = align + } + } + col.indentAmount = options.NonJSONExtraIndent + 1 + adjusted = append(adjusted, col) + } + return adjusted +} + +func (p *Printer) printTable(options *TableOptions, cols []*col, rows []map[string]colVal) { + if !options.NoHeader { + p.printHeader(cols) + } + p.printRows(cols, rows) +} + +func (p *Printer) printHeader(cols []*col) { + colorer := p.TableHeaderColorer + if colorer == nil { + colorer = color.MagentaString + } + for _, col := range cols { + for i := 0; i < col.indentAmount; i++ { + p.writeStr(NonJSONIndent) + } + p.writeStr(tablewriter.Pad(colorer("%v", col.name), " ", col.width)) + } + p.writeStr("\n") +} + +func (p *Printer) printRows(cols []*col, rows []map[string]colVal) { + for _, row := range rows { + p.printRow(cols, row) + } +} + +func (p *Printer) printRow(cols []*col, row map[string]colVal) { + for _, col := range cols { + for i := 0; i < col.indentAmount; i++ { + p.writeStr(NonJSONIndent) + } + p.printCol(col, row[col.name].text) + } + p.writeStr("\n") +} + +func (p *Printer) printCol(col *col, data string) { + switch col.align { + case AlignCenter: + data = tablewriter.Pad(data, " ", col.width) + case AlignRight: + data = tablewriter.PadLeft(data, " ", col.width) + default: + data = tablewriter.PadRight(data, " ", col.width) + } + p.writeStr(data) +} + +func (p *Printer) printCards(cols []*col, rows []map[string]colVal) { + for i, row := range rows { + // Extra newline between cards + if i > 0 { + p.writeStr("\n") + } + p.printCard(cols, row) + } +} + +func (p *Printer) printCard(cols []*col, row map[string]colVal) { + nameValueRows := make([]map[string]colVal, 0, len(cols)) + indentAmount := 1 + // Since this option applies to everything in a structured print, there should be + // no difference among columns + if len(cols) > 0 { + indentAmount = cols[0].indentAmount + } + for _, col := range cols { + rowVal := row[col.name].val + if !col.cardOmitEmpty || (rowVal != nil && !reflect.ValueOf(row[col.name].val).IsZero()) { + nameValueRows = append(nameValueRows, map[string]colVal{ + "Name": {val: col.name, text: col.name}, + "Value": row[col.name], + }) + } + } + nameValueCols := []*col{ + {name: "Name", indentAmount: indentAmount}, + // We want to set the width to 1 here, because we want it to stretch as far + // as it needs to the right + {name: "Value", width: 1, indentAmount: indentAmount}, + } + p.calculateUnsetColWidths(nameValueCols, nameValueRows) + p.printRows(nameValueCols, nameValueRows) +} + +var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + +func (p *Printer) textVal(v any) string { + if ref := reflect.Indirect(reflect.ValueOf(v)); ref.IsValid() { + if ref.Type() == reflect.TypeOf(time.Time{}) { + if ref.IsZero() { + return "" + } + if p.FormatTime == nil { + return ref.Interface().(time.Time).Format(time.RFC3339) + } + return p.FormatTime(ref.Interface().(time.Time)) + } else if (ref.Kind() == reflect.Struct && ref.CanInterface()) || ref.Type().Implements(jsonMarshalerType) { + b, err := p.jsonVal(v, "", true) + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) + } else if ref.Kind() == reflect.Slice && ref.Type().Elem().Kind() == reflect.Uint8 { + b, _ := ref.Interface().([]byte) + return "bytes(" + base64.StdEncoding.EncodeToString(b) + ")" + } else if ref.Kind() == reflect.Slice { + // We don't want to reimplement all of fmt.Sprintf, but expanding one level of + // slice helps format lists more consistently. + var sb strings.Builder + sb.WriteString("[") + for i := 0; i < ref.Len(); i++ { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(p.textVal(ref.Index(i).Interface())) + } + sb.WriteString("]") + return sb.String() + } + } + return fmt.Sprintf("%v", v) +} + +func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []map[string]colVal, err error) { + singleItemType := reflect.TypeOf(v) + if singleItemType.Kind() == reflect.Slice { + singleItemType = singleItemType.Elem() + } else { + sliceVal := reflect.MakeSlice(reflect.SliceOf(singleItemType), 1, 1) + sliceVal.Index(0).Set(reflect.ValueOf(v)) + v = sliceVal.Interface() + } + + // Validate and create field getter + cols = predefinedCols + if len(cols) == 0 { + var err error + if cols, err = deriveCols(singleItemType); err != nil { + return nil, nil, err + } + } + colValGetter, err := colValGetterForType(singleItemType) + if err != nil { + return nil, nil, err + } + + // Build data + sliceVal := reflect.ValueOf(v) + rows = make([]map[string]colVal, sliceVal.Len()) + for i := range rows { + itemVal := sliceVal.Index(i) + row := make(map[string]colVal, len(cols)) + for _, col := range cols { + colVal := colVal{val: colValGetter(col, itemVal)} + colVal.text = p.textVal(colVal.val) + row[col.name] = colVal + } + rows[i] = row + } + return +} + +func (p *Printer) tableRowData(cols []*col, v any) (map[string]colVal, error) { + colValGetter, err := colValGetterForType(reflect.TypeOf(v)) + if err != nil { + return nil, err + } + row := make(map[string]colVal, len(cols)) + itemVal := reflect.ValueOf(v) + for _, col := range cols { + colVal := colVal{val: colValGetter(col, itemVal)} + colVal.text = p.textVal(colVal.val) + row[col.name] = colVal + } + return row, nil +} + +func colValGetterForType(t reflect.Type) (func(col *col, v reflect.Value) any, error) { + switch t.Kind() { + case reflect.Map: + return func(col *col, v reflect.Value) any { + v = v.MapIndex(reflect.ValueOf(col.name)) + if !v.IsValid() { + return nil + } + return v.Interface() + }, nil + case reflect.Struct: + return func(col *col, v reflect.Value) any { + return v.FieldByName(col.name).Interface() + }, nil + case reflect.Pointer: + if t.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("expected map, struct, or pointer to struct, got: %v", t) + } + return func(col *col, v reflect.Value) any { + return v.Elem().FieldByName(col.name).Interface() + }, nil + default: + return nil, fmt.Errorf("expected map, struct, or pointer to struct, got: %v", t) + } +} + +func deriveCols(t reflect.Type) ([]*col, error) { + switch t.Kind() { + case reflect.Map: + return nil, fmt.Errorf("cannot derive fields from map") + case reflect.Pointer: + if t.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("expected map, struct, or pointer to struct, got: %v", t) + } + return deriveCols(t.Elem()) + case reflect.Struct: + cols := make([]*col, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + if col := deriveColFromField(t.Field(i)); col != nil { + cols = append(cols, col) + } + } + return cols, nil + default: + return nil, fmt.Errorf("expected map, struct, or pointer to struct, got: %v", t) + } +} + +// Nil if does not apply +func deriveColFromField(f reflect.StructField) *col { + // Must be exported + if !f.IsExported() { + return nil + } + col := &col{name: f.Name} + // Default to align right for numbers + switch f.Type.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: + col.align = AlignRight + } + // Handle tag + for i, tagPart := range strings.Split(f.Tag.Get("cli"), ",") { + switch { + case i == 0: + // Don't allow name customization currently + if tagPart != "" { + panic("expected cli tag to have empty name") + } + case tagPart == "omit": + return nil + case tagPart == "cardOmitEmpty": + col.cardOmitEmpty = true + case strings.HasPrefix(tagPart, "width="): + var err error + if col.width, err = strconv.Atoi(strings.TrimPrefix(tagPart, "width=")); err != nil { + panic(err) + } + case strings.HasPrefix(tagPart, "align="): + switch align := strings.TrimPrefix(tagPart, "align="); align { + case "default": + col.align = AlignLeft + case "center": + col.align = AlignCenter + case "right": + col.align = AlignRight + case "left": + col.align = AlignLeft + default: + panic("unrecognized align: " + align) + } + default: + panic("unrecognized CLI tag: " + tagPart) + } + } + // Also consider json tags to allow omitting empty cards if the json field would also be omitted + for _, tagPart := range strings.Split(f.Tag.Get("json"), ",") { + switch tagPart { + case "omitempty": + col.cardOmitEmpty = true + } + } + return col +} diff --git a/temporalcloudcli/internal/printer/printer_test.go b/temporalcloudcli/internal/printer/printer_test.go new file mode 100644 index 0000000..57ca88f --- /dev/null +++ b/temporalcloudcli/internal/printer/printer_test.go @@ -0,0 +1,182 @@ +package printer_test + +import ( + "bytes" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "unicode" + + "github.com/stretchr/testify/require" + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +// TODO(cretz): Test: +// * Text printer specific fields +// * Text printer specific and non-specific fields and all sorts of table options +// * JSON printer + +func TestPrinter_Text(t *testing.T) { + type MyStruct struct { + Foo string + Bar bool + unexportedBaz string + ReallyLongField any + Omitted string `cli:",omit"` + OmittedCardEmpty string `cli:",cardOmitEmpty"` + } + var buf bytes.Buffer + p := printer.Printer{Output: &buf} + // Simple struct non-table no fields set + require.NoError(t, p.PrintStructured([]*MyStruct{ + { + Foo: "1", + unexportedBaz: "2", + ReallyLongField: struct { + Key any `json:"key"` + }{Key: 123}, + Omitted: "value", + OmittedCardEmpty: "value", + }, + { + Foo: "not-a-number", + Bar: true, + ReallyLongField: map[string]int{"": 0}, + }, + }, printer.StructuredOptions{})) + // Check + require.Equal(t, normalizeMultiline(` + Foo 1 + Bar false + ReallyLongField {"key":123} + OmittedCardEmpty value + + Foo not-a-number + Bar true + ReallyLongField map[:0]`), normalizeMultiline(buf.String())) + + // TODO(cretz): Tables and more options +} + +func normalizeMultiline(s string) string { + // Split lines, trim trailing space on each (also removes \r), remove empty + // lines, re-join + var ret string + for _, line := range strings.Split(s, "\n") { + line = strings.TrimRightFunc(line, unicode.IsSpace) + // Only non-empty lines + if line != "" { + if ret != "" { + ret += "\n" + } + ret += line + } + } + return ret +} + +func TestPrinter_JSON(t *testing.T) { + var buf bytes.Buffer + + // With indentation + p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.Equal(t, `{ + "foo": "bar" +} +`, buf.String()) + + // Without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.Equal(t, "{\"foo\":\"bar\"}\n", buf.String()) +} + +func TestPrinter_JSONList(t *testing.T) { + var buf bytes.Buffer + + // With indentation + p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) + p.EndList() + require.Equal(t, `[ +{ + "foo": "bar" +}, +{ + "baz": "qux" +} +] +`, buf.String()) + + // Without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) + p.EndList() + require.Equal(t, "{\"foo\":\"bar\"}\n{\"baz\":\"qux\"}\n", buf.String()) + + // Empty with indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "[\n]\n", buf.String()) + + // Empty without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "", buf.String()) +} + +// Asserts the printer package don't panic if the CLI is run without a STDOUT. +// This is a tricky thing to validate, as it must be done in a subprocess and as +// `go test` has its own internal fix for improper STDOUT. This was fixed in +// Go 1.22, but keeping this here as a regression test. +// See https://github.com/temporalio/cli/issues/544. +func TestPrinter_NoPanicIfNoStdout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipped on Windows") + return + } + + goPath, err := exec.LookPath("go") + if err != nil { + t.Fatalf("Error finding go executable: %v", err) + } + // Don't use exec.Command here, as it silently replaces nil file descriptors + // with /dev/null on the parent side. We specifically want to test what + // happens when stdout is nil. + p, err := os.StartProcess( + goPath, + []string{"go", "run", "./test/main.go"}, + &os.ProcAttr{ + Files: []*os.File{os.Stdin, nil, os.Stderr}, + }, + ) + if err != nil { + t.Fatalf("Error running command: %v", err) + } + state, err := p.Wait() + if err != nil { + t.Fatalf("Error running command: %v", err) + } + if state.ExitCode() != 0 { + t.Fatalf("Error running command; exit code = %d", state.ExitCode()) + } +} diff --git a/temporalcloudcli/login.go b/temporalcloudcli/login.go new file mode 100644 index 0000000..571c8a0 --- /dev/null +++ b/temporalcloudcli/login.go @@ -0,0 +1,63 @@ +package temporalcloudcli + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +var ( + defaultConfigDir string +) + +func init() { + defaultConfigDir = filepath.Join(os.Getenv("HOME"), ".config", "temporal-cloud-cli") +} + +func parseURL(s string) (*url.URL, error) { + // Without a scheme, url.Parse would interpret the path as a relative file path. + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { + s = fmt.Sprintf("%s%s", "https://", s) + } + + u, err := url.ParseRequestURI(s) + if err != nil { + return nil, err + } + + if u.Scheme == "" { + u.Scheme = "https" + } + + return u, err +} + +func openBrowser(cctx *CommandContext, message string, url string) error { + // Print to stderr so other tooling can parse the command output. + fmt.Fprintf(os.Stderr, "%s: %s\n", message, url) + + if cctx.RootCommand.DisablePopUp { + return nil + } + + switch runtime.GOOS { + case "linux": + if err := exec.Command("xdg-open", url).Start(); err != nil { + return err + } + case "windows": + if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start(); err != nil { + return err + } + case "darwin": + if err := exec.Command("open", url).Start(); err != nil { + return err + } + default: + } + return nil +} diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go new file mode 100644 index 0000000..6f885ea --- /dev/null +++ b/temporalcloudcli/namespace.go @@ -0,0 +1,141 @@ +package temporalcloudcli + +import ( + "context" + "fmt" + + "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "go.temporal.io/cloud-sdk/api/operation/v1" + "go.temporal.io/cloud-sdk/cloudclient" +) + +type namespaceClient struct { + client *cloudclient.Client +} + +type namespaceOpt func(*namespaceClient) + +func withCloudClient(cloudClient *cloudclient.Client) namespaceOpt { + return func(nc *namespaceClient) { + nc.client = cloudClient + } +} + +func newNamespaceClient(opts ...namespaceOpt) *namespaceClient { + namespaceClient := &namespaceClient{} + + for _, opt := range opts { + opt(namespaceClient) + } + + return namespaceClient +} + +func (c *namespaceClient) getNamespace(ctx context.Context, namespace string) (*namespace.Namespace, error) { + res, err := c.client.CloudService().GetNamespace(ctx, &cloudservice.GetNamespaceRequest{ + Namespace: namespace, + }) + + if err != nil { + return nil, err + } + + if res.Namespace == nil || res.Namespace.Namespace == "" { + // this should never happen, the server should return an error when the namespace is not found + return nil, fmt.Errorf("invalid namespace returned by server") + } + return res.Namespace, nil +} + +type updateNamespaceParams struct { + asyncOperationID string + idempotent bool + resourceVersion string + // namespace is the full name of the namespace including the account + namespace string +} + +func (c *namespaceClient) updateNamespace(ctx context.Context, n *namespace.NamespaceSpec, params updateNamespaceParams) (*operation.AsyncOperation, error) { + res, err := c.client.CloudService().UpdateNamespace(ctx, &cloudservice.UpdateNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Namespace: params.namespace, + ResourceVersion: params.resourceVersion, + Spec: n, + }) + if err != nil { + if isNothingChangedErr(params.idempotent, err) { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, nil +} + +func (c *namespaceClient) createNamespace(ctx context.Context, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { + res, err := c.client.CloudService().CreateNamespace(ctx, &cloudservice.CreateNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Spec: n, + }) + if err != nil { + if isNothingChangedErr(params.idempotent, err) { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, nil +} + +type applyNamespaceParams struct { + asyncOperationID string + idempotent bool +} + +func (c *namespaceClient) applyNamespace(ctx context.Context, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { + // Try to get the existing namespace + namespaces, err := c.listNamespacesWithName(ctx, n.GetName()) + if err != nil { + return nil, err + } else if len(namespaces) > 1 { + return nil, fmt.Errorf("multiple namespaces match namespace name: %s", n.GetName()) + } else if len(namespaces) == 0 { + return c.createNamespace(ctx, n, params) + } + + existing := namespaces[0] + + // update + // Namespace exists, update it using the current resource version + updateParams := updateNamespaceParams{ + asyncOperationID: params.asyncOperationID, + idempotent: params.idempotent, + resourceVersion: existing.ResourceVersion, + namespace: existing.Namespace, + } + + return c.updateNamespace(ctx, n, updateParams) +} + +// TODO: (gmankes) short circuit after one page and if there are more than one namespace +func (c *namespaceClient) listNamespacesWithName(ctx context.Context, name string) ([]*namespace.Namespace, error) { + namespaces := []*namespace.Namespace{} + pageToken := "" + for { + res, err := c.client.CloudService().GetNamespaces(ctx, &cloudservice.GetNamespacesRequest{ + Name: name, + PageToken: pageToken, + }) + if err != nil { + return nil, err + } + namespaces = append(namespaces, res.Namespaces...) + // Check if we should continue paging + pageToken = res.NextPageToken + if len(pageToken) == 0 { + break + } + } + return namespaces, nil +} diff --git a/temporalcloudcli/oauth.go b/temporalcloudcli/oauth.go new file mode 100644 index 0000000..2fc9f5f --- /dev/null +++ b/temporalcloudcli/oauth.go @@ -0,0 +1,96 @@ +package temporalcloudcli + +import ( + "fmt" + "os" + + "golang.org/x/oauth2" +) + +const ( + // OAuth error defined in RFC-6749. + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + invalidGrantErr = "invalid_grant" +) + +const ( + loginDefaultClientID = "d7V5bZMLCbRLfRVpqC567AqjAERaWHhl" +) + +func login(cctx *CommandContext, tokenConfig *TokenConfig) (*TokenConfig, error) { + if tokenConfig == nil { + defaultConfig, err := defaultTokenConfig(cctx) + if err != nil { + return nil, err + } + tokenConfig = defaultConfig + } + + resp, err := tokenConfig.OAuthConfig.DeviceAuth(cctx.Context, oauth2.SetAuthURLParam("audience", tokenConfig.Audience)) + if err != nil { + return nil, fmt.Errorf("failed to perform device auth: %w", err) + } + + domainURL, err := parseURL(tokenConfig.Domain) + if err != nil { + return nil, fmt.Errorf("failed to parse domain: %w", err) + } + + verificationURL, err := parseURL(resp.VerificationURIComplete) + if err != nil { + return nil, fmt.Errorf("failed to parse verification URL: %w", err) + } else if verificationURL.Hostname() != domainURL.Hostname() { + // We expect the verification URL to be the same host as the domain URL. + // Otherwise the response could have us POST to any arbitrary URL. + return nil, fmt.Errorf("domain URL `%s` does not match verification URL `%s` in response", domainURL.Hostname(), verificationURL.Hostname()) + } + + err = openBrowser(cctx, "Login via this url", verificationURL.String()) + if err != nil { + // Notify the user but ensure they can continue the process. + fmt.Printf("Failed to open the browser, click the link to continue: %v", err) + } + + token, err := tokenConfig.OAuthConfig.DeviceAccessToken(cctx.Context, resp) + if err != nil { + return nil, fmt.Errorf("failed to retrieve access token: %w", err) + } + // Print to stderr so other tooling can parse the command output. + fmt.Fprintln(os.Stderr, "Successfully logged in!") + + tokenConfig.OAuthToken = token + tokenConfig.cctx = cctx + + err = tokenConfig.Store() + if err != nil { + return nil, fmt.Errorf("failed to store token config: %w", err) + } + + return tokenConfig, nil +} + +func defaultTokenConfig(cctx *CommandContext) (*TokenConfig, error) { + domainURL, err := parseURL(cctx.RootCommand.Domain) + if err != nil { + return nil, fmt.Errorf("failed to parse domain URL: %w", err) + } + clientID := loginDefaultClientID + if cctx.RootCommand.ClientId != "" { + clientID = cctx.RootCommand.ClientId + } + + return &TokenConfig{ + Audience: cctx.RootCommand.Audience, + Domain: domainURL.String(), + OAuthConfig: oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "profile", "user", "offline_access"}, + }, + cctx: cctx, + }, nil +} diff --git a/temporalcloudcli/strings.go b/temporalcloudcli/strings.go new file mode 100644 index 0000000..77f986f --- /dev/null +++ b/temporalcloudcli/strings.go @@ -0,0 +1,124 @@ +package temporalcloudcli + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +type StringEnum struct { + Allowed []string + Value string + ChangedFromDefault bool +} + +func NewStringEnum(allowed []string, value string) StringEnum { + return StringEnum{Allowed: allowed, Value: value} +} + +func (s *StringEnum) String() string { return s.Value } + +func (s *StringEnum) Set(p string) error { + for _, allowed := range s.Allowed { + if p == allowed { + s.Value = p + s.ChangedFromDefault = true + return nil + } + } + return fmt.Errorf("%v is not one of required values of %v", p, strings.Join(s.Allowed, ", ")) +} + +func (*StringEnum) Type() string { return "string" } + +type StringEnumArray struct { + // maps lower case value to original case + Allowed map[string]string + // values in original case + Values []string +} + +func NewStringEnumArray(allowed []string, values []string) StringEnumArray { + // maps lower case value to original case so we can do case insensitive comparison, + // while maintaining original case + var allowedMap = make(map[string]string) + for _, str := range allowed { + allowedMap[strings.ToLower(str)] = str + } + + return StringEnumArray{Allowed: allowedMap, Values: values} +} + +func (s *StringEnumArray) String() string { return strings.Join(s.Values, ",") } + +func (s *StringEnumArray) Set(p string) error { + val, ok := s.Allowed[strings.ToLower(p)] + if !ok { + values := make([]string, 0, len(s.Allowed)) + for _, v := range s.Allowed { + values = append(values, v) + } + return fmt.Errorf("invalid value: %s, allowed values are: %s", p, strings.Join(values, ", ")) + } + s.Values = append(s.Values, val) + return nil +} + +func (*StringEnumArray) Type() string { return "string" } + +func stringToProtoEnum[T ~int32](s string, maps ...map[string]int32) (T, error) { + // Go over each map looking, if not there, use first map to build set of + // strings required + for _, m := range maps { + for k, v := range m { + if strings.EqualFold(k, s) { + return T(v), nil + } + } + } + keys := make([]string, 0, len(maps[0])) + for k := range maps[0] { + keys = append(keys, k) + } + sort.Strings(keys) + return 0, fmt.Errorf("unknown value %q, expected one of: %v", s, strings.Join(keys, ", ")) +} + +func stringKeysValues(s []string) (map[string]string, error) { + ret := make(map[string]string, len(s)) + for _, item := range s { + pieces := strings.SplitN(item, "=", 2) + if len(pieces) != 2 { + return nil, fmt.Errorf("missing expected '=' in %q", item) + } + ret[pieces[0]] = pieces[1] + } + return ret, nil +} + +func stringKeysJSONValues(s []string, useJSONNumber bool) (map[string]any, error) { + if len(s) == 0 { + return nil, nil + } + ret := make(map[string]any, len(s)) + for _, item := range s { + pieces := strings.SplitN(item, "=", 2) + if len(pieces) != 2 { + return nil, fmt.Errorf("missing expected '=' in %q", item) + } + dec := json.NewDecoder(bytes.NewReader([]byte(pieces[1]))) + if useJSONNumber { + dec.UseNumber() + } + var v any + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("invalid JSON value for key %q: %w", pieces[0], err) + } else if dec.InputOffset() != int64(len(pieces[1])) { + return nil, fmt.Errorf("invalid JSON value for key %q: unexpected trailing data", pieces[0]) + } + ret[pieces[0]] = v + } + return ret, nil +} diff --git a/temporalcloudcli/token_config.go b/temporalcloudcli/token_config.go new file mode 100644 index 0000000..bb1e3cc --- /dev/null +++ b/temporalcloudcli/token_config.go @@ -0,0 +1,169 @@ +package temporalcloudcli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/oauth2" +) + +const ( + tokenConfigFile = "tokens.json" +) + +type TokenConfig struct { + Audience string `json:"audience"` + Domain string `json:"domain"` + OAuthConfig oauth2.Config `json:"oauth_config"` + OAuthToken *oauth2.Token `json:"oauth_token"` + + cctx *CommandContext +} + +func getConfigDir(cctx *CommandContext) string { + cfgDir := cctx.RootCommand.ConfigDir + if cfgDir == "" { + cfgDir = defaultConfigDir + } + return cfgDir +} + +func loadTokenConfig(cctx *CommandContext) (*TokenConfig, error) { + tokenConfig := filepath.Join(getConfigDir(cctx), tokenConfigFile) + + _, err := os.Stat(tokenConfig) + if err != nil { + if os.IsNotExist(err) { + cfg, err := login(cctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + return cfg, nil + } + + return nil, fmt.Errorf("failed to stat login config: %w", err) + } + + data, err := os.ReadFile(tokenConfig) + if err != nil { + return nil, fmt.Errorf("failed to read login config: %w", err) + } + + var config *TokenConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal login config: %w", err) + } else if config.OAuthToken == nil { + // Using legacy token format, ask user to initiate a login to migrate. + fmt.Println("Re-login with `tcld login` to migrate to the new config format") + os.Exit(1) + } + + config.cctx = cctx // used for token refreshes. + + return config, nil +} + +func (c *TokenConfig) TokenSource() oauth2.TokenSource { + if c == nil { + return nil + } + + return oauth2.ReuseTokenSource(nil, c) +} + +func (c *TokenConfig) Token() (*oauth2.Token, error) { + if c == nil { + return nil, fmt.Errorf("nil token source") + } + + grace := c.OAuthToken.Expiry.Add(-1 * time.Minute) + if c.OAuthToken.Expiry.IsZero() || time.Now().Before(grace) { + // Token has not expired, or is a legacy token, use it. + return c.OAuthToken, nil + } + + // Token has expired, refresh it. + token, err := c.OAuthConfig.TokenSource(c.cctx.Context, c.OAuthToken).Token() + if err != nil { + var retrieveErr *oauth2.RetrieveError + + // Handle one of two cases: + // 1. Refresh token has expired. + // 2. Refresh tokens were enabled, but the user has not logged in to receive one yet. + if (errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == invalidGrantErr) || + len(c.OAuthToken.RefreshToken) == 0 { + cfg, err := login(c.cctx, c) + if err != nil { + return nil, fmt.Errorf("failed to login to retrieve new refresh token: %w", err) + } + + token, err = cfg.OAuthConfig.TokenSource(cfg.cctx.Context, cfg.OAuthToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh access token after login: %w", err) + } + + // Make sure the current config reflects the new config. + c.OAuthConfig = cfg.OAuthConfig + c.OAuthToken = token + + // Store the new config for the next CLI invocation. + err = cfg.Store() + if err != nil { + return nil, fmt.Errorf("failed to store new refresh and access tokens: %w", err) + } + + return token, nil + } + + return nil, fmt.Errorf("failed to refresh access token: %w", err) + } + + c.OAuthToken = token + err = c.Store() + if err != nil { + return nil, fmt.Errorf("failed to store refreshed token: %w", err) + } + + return token, nil +} + +func (c *TokenConfig) Store() error { + cfgDir := getConfigDir(c.cctx) + + data, err := FormatJson(c) + if err != nil { + return fmt.Errorf("failed to format login config update: %w", err) + } + + // Create config dir if it does not exist + if err := os.MkdirAll(cfgDir, 0700); err != nil { + return err + } + + // Write file as 0600 because it contains private keys. + return os.WriteFile(filepath.Join(cfgDir, tokenConfigFile), []byte(data), 0600) +} + +func FormatJson(i interface{}) (string, error) { + resJson, err := json.MarshalIndent(i, "", " ") + if err != nil { + return "", err + } + return fmt.Sprintf("%v\n", string(resJson)), nil +} + +// GetAPIKey implents the APIKeyReader interface +func (c *TokenConfig) GetAPIKey(ctx context.Context) (string, error) { + token, err := c.Token() + if err != nil { + return "", err + } + + return token.AccessToken, nil +}