From 0ef9f565824bf98043f450e7210c84252a5c69d2 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Tue, 4 Nov 2025 11:59:06 -0500 Subject: [PATCH 01/23] initial scaffolding --- .gitignore | 7 +- CODEOWNERS | 8 + Makefile | 11 + README.md | 2 +- cmd/cloud-cli/main.go | 13 + go.mod | 71 ++ go.sum | 210 ++++++ temporalcloudcli/cloud.go | 25 + temporalcloudcli/commands.gen.go | 137 ++++ temporalcloudcli/commands.go | 573 ++++++++++++++++ temporalcloudcli/commands.login.go | 6 + temporalcloudcli/commands.namespace.go | 21 + temporalcloudcli/commandsgen/code.go | 441 +++++++++++++ temporalcloudcli/commandsgen/commands.yml | 158 +++++ temporalcloudcli/commandsgen/docs.go | 192 ++++++ temporalcloudcli/commandsgen/parse.go | 249 +++++++ temporalcloudcli/common.go | 21 + temporalcloudcli/duration.go | 30 + .../internal/cmd/gen-commands/main.go | 41 ++ .../internal/cmd/gen-docs/main.go | 50 ++ temporalcloudcli/internal/printer/printer.go | 611 ++++++++++++++++++ .../internal/printer/printer_test.go | 182 ++++++ temporalcloudcli/login.go | 63 ++ temporalcloudcli/namespace.go | 72 +++ temporalcloudcli/oauth.go | 96 +++ temporalcloudcli/payload.go | 42 ++ temporalcloudcli/sqlite_test.go | 24 + temporalcloudcli/strings.go | 124 ++++ temporalcloudcli/timestamp.go | 26 + temporalcloudcli/token_config.go | 169 +++++ 30 files changed, 3671 insertions(+), 4 deletions(-) create mode 100644 CODEOWNERS create mode 100644 Makefile create mode 100644 cmd/cloud-cli/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 temporalcloudcli/cloud.go create mode 100644 temporalcloudcli/commands.gen.go create mode 100644 temporalcloudcli/commands.go create mode 100644 temporalcloudcli/commands.login.go create mode 100644 temporalcloudcli/commands.namespace.go create mode 100644 temporalcloudcli/commandsgen/code.go create mode 100644 temporalcloudcli/commandsgen/commands.yml create mode 100644 temporalcloudcli/commandsgen/docs.go create mode 100644 temporalcloudcli/commandsgen/parse.go create mode 100644 temporalcloudcli/common.go create mode 100644 temporalcloudcli/duration.go create mode 100644 temporalcloudcli/internal/cmd/gen-commands/main.go create mode 100644 temporalcloudcli/internal/cmd/gen-docs/main.go create mode 100644 temporalcloudcli/internal/printer/printer.go create mode 100644 temporalcloudcli/internal/printer/printer_test.go create mode 100644 temporalcloudcli/login.go create mode 100644 temporalcloudcli/namespace.go create mode 100644 temporalcloudcli/oauth.go create mode 100644 temporalcloudcli/payload.go create mode 100644 temporalcloudcli/sqlite_test.go create mode 100644 temporalcloudcli/strings.go create mode 100644 temporalcloudcli/timestamp.go create mode 100644 temporalcloudcli/token_config.go diff --git a/.gitignore b/.gitignore index aaadf73..0dd06ad 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,5 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ 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..ec0a9e7 --- /dev/null +++ b/go.mod @@ -0,0 +1,71 @@ +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 + modernc.org/sqlite v1.34.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/hashicorp/golang-lru/v2 v2.0.7 // 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/ncruces/go-strftime v0.1.9 // indirect + github.com/nexus-rpc/sdk-go v0.3.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 + modernc.org/gc/v3 v3.0.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect + modernc.org/strutil v1.2.1 // indirect + modernc.org/token v1.1.0 // 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..7b7e4e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,210 @@ +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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +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/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +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= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.20.4 h1:3pPOlMcblnu5CBU3w1BFtepwBnLezGjPYTH8xBeYZM8= +modernc.org/ccgo/v4 v4.20.4/go.mod h1:meYiLeaGpKQmHBw8roW4DXLkDvusG+MD7LJ/kYyAouU= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.0.0 h1:JNEAEd0e/lnR1nlJemLPwS44KfBLBp4SAvZEZFaxfYU= +modernc.org/gc/v3 v3.0.0/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/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..df4a679 --- /dev/null +++ b/temporalcloudcli/commands.gen.go @@ -0,0 +1,137 @@ +// 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(&NewCloudNamespaceGetCommand(cctx, &s).Command) + 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. 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..6911fe4 --- /dev/null +++ b/temporalcloudcli/commands.namespace.go @@ -0,0 +1,21 @@ +package temporalcloudcli + +import ( + "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{}) +} 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..4408f52 --- /dev/null +++ b/temporalcloudcli/commandsgen/commands.yml @@ -0,0 +1,158 @@ +# 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: Manage namespaces + 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. + short: n \ No newline at end of file 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..e699aa6 --- /dev/null +++ b/temporalcloudcli/common.go @@ -0,0 +1,21 @@ +package temporalcloudcli + +import ( + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +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") +} 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..8a2368e --- /dev/null +++ b/temporalcloudcli/namespace.go @@ -0,0 +1,72 @@ +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 +} + +func (c *namespaceClient) updateNamespace(ctx context.Context, n *namespace.Namespace, params updateNamespaceParams) (*operation.AsyncOperation, error) { + res, err := c.client.CloudService().UpdateNamespace(ctx, &cloudservice.UpdateNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Namespace: n.Namespace, + ResourceVersion: params.resourceVersion, + Spec: n.Spec, + }) + if err != nil { + if isNothingChangedErr(params.idempotent, err) { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, 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/payload.go b/temporalcloudcli/payload.go new file mode 100644 index 0000000..06586a0 --- /dev/null +++ b/temporalcloudcli/payload.go @@ -0,0 +1,42 @@ +package temporalcloudcli + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "go.temporal.io/api/common/v1" +) + +// CreatePayloads creates API Payload objects from given data and metadata slices. +// If metadata has an entry at a data index, it is used, otherwise it uses the metadata entry at index 0. +func CreatePayloads(data [][]byte, metadata map[string][][]byte, isBase64 bool) (*common.Payloads, error) { + ret := &common.Payloads{Payloads: make([]*common.Payload, len(data))} + for i, in := range data { + var metadataForIndex = make(map[string][]byte, len(metadata)) + for k, vals := range metadata { + if len(vals) == 0 { + continue + } + v := vals[0] + if len(vals) > i { + v = vals[i] + } + // If it's JSON, validate it + if k == "encoding" && strings.HasPrefix(string(v), "json/") && !json.Valid(in) { + return nil, fmt.Errorf("input #%v is not valid JSON", i+1) + } + metadataForIndex[k] = v + } + // Decode base64 if base64'd (std encoding only for now) + if isBase64 { + var err error + if in, err = base64.StdEncoding.DecodeString(string(in)); err != nil { + return nil, fmt.Errorf("input #%v is not valid base64", i+1) + } + } + ret.Payloads[i] = &common.Payload{Data: in, Metadata: metadataForIndex} + } + return ret, nil +} diff --git a/temporalcloudcli/sqlite_test.go b/temporalcloudcli/sqlite_test.go new file mode 100644 index 0000000..b90eac6 --- /dev/null +++ b/temporalcloudcli/sqlite_test.go @@ -0,0 +1,24 @@ +package temporalcloudcli + +import ( + "os" + "strings" + "testing" + + _ "modernc.org/sqlite" +) + +// Pinning modernc.org/sqlite to this version until https://gitlab.com/cznic/sqlite/-/issues/196 is resolved +func TestSqliteVersion(t *testing.T) { + content, err := os.ReadFile("../go.mod") + if err != nil { + t.Fatalf("Failed to read go.mod: %v", err) + } + contentStr := string(content) + if !strings.Contains(contentStr, "modernc.org/sqlite v1.34.1") { + t.Errorf("go.mod missing dependency modernc.org/sqlite v1.34.1") + } + if !strings.Contains(contentStr, "modernc.org/libc v1.55.3") { + t.Errorf("go.mod missing dependency modernc.org/libc v1.55.3") + } +} 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/timestamp.go b/temporalcloudcli/timestamp.go new file mode 100644 index 0000000..fabf6ab --- /dev/null +++ b/temporalcloudcli/timestamp.go @@ -0,0 +1,26 @@ +package temporalcloudcli + +import "time" + +type Timestamp time.Time + +func (t Timestamp) Time() time.Time { + return time.Time(t) +} + +func (t *Timestamp) String() string { + return t.Time().Format(time.RFC3339) +} + +func (t *Timestamp) Set(s string) error { + p, err := time.Parse(time.RFC3339, s) + if err != nil { + return err + } + *t = Timestamp(p) + return nil +} + +func (t *Timestamp) Type() string { + return "timestamp" +} 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 +} From b33593b89aba90940c4d341ba0a7d7858273ad4e Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 26 Nov 2025 14:42:48 -0500 Subject: [PATCH 02/23] add apply, edit commands --- .gitignore | 4 + go.mod | 2 +- temporalcloudcli/commands.gen.go | 70 +++++++++- temporalcloudcli/commands.namespace.go | 152 ++++++++++++++++++++++ temporalcloudcli/commandsgen/commands.yml | 68 +++++++++- temporalcloudcli/common.go | 91 +++++++++++++ temporalcloudcli/namespace.go | 74 ++++++++++- 7 files changed, 454 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 0dd06ad..e28278c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ go.work.sum # Editor/IDE .idea/ .vscode/ + +# bots and other local information +.local/ +.claude/ \ No newline at end of file diff --git a/go.mod b/go.mod index ec0a9e7..897164f 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc 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 diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index df4a679..bb8bb0d 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -108,10 +108,78 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud 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 +} + +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.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 +} + +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.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 @@ -126,7 +194,7 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom 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. Required.") + 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 { diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 6911fe4..756a450 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -1,6 +1,10 @@ package temporalcloudcli import ( + "fmt" + + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) @@ -19,3 +23,151 @@ func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, args []string) erro 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 + } + + 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{}) + } + + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) +} + +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: Print async operation using PrintStructured + // The asyncOp is a proto message, so PrintStructured will handle it correctly + // This will output the async operation details including the operation ID + // We do NOT wait for the operation to complete + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) +} + +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/commands.yml b/temporalcloudcli/commandsgen/commands.yml index 4408f52..ac97e43 100644 --- a/temporalcloudcli/commandsgen/commands.yml +++ b/temporalcloudcli/commandsgen/commands.yml @@ -154,5 +154,69 @@ commands: - name: namespace required: true type: string - description: The namespace to get. - short: n \ No newline at end of file + 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: 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. diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index e699aa6..8d9185b 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -1,10 +1,19 @@ package temporalcloudcli import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "os" + "os/exec" "strings" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) func isNothingChangedErr(idempotent bool, e error) bool { @@ -19,3 +28,85 @@ func isNothingChangedErr(idempotent bool, e error) bool { } 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 +} diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index 8a2368e..7ca67cc 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -52,14 +52,16 @@ 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.Namespace, params updateNamespaceParams) (*operation.AsyncOperation, error) { +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: n.Namespace, + Namespace: params.namespace, ResourceVersion: params.resourceVersion, - Spec: n.Spec, + Spec: n, }) if err != nil { if isNothingChangedErr(params.idempotent, err) { @@ -70,3 +72,69 @@ func (c *namespaceClient) updateNamespace(ctx context.Context, n *namespace.Name return res.AsyncOperation, nil } + +type applyNamespaceParams struct { + asyncOperationID string + idempotent bool +} + +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 +} + +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) +} + +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 +} From a88c2a7d849d7e3587e18be08c1d02cc2fed4fd0 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Tue, 2 Dec 2025 12:50:45 -0500 Subject: [PATCH 03/23] add polling --- temporalcloudcli/commands.gen.go | 4 ++ temporalcloudcli/commands.namespace.go | 22 ++++++--- temporalcloudcli/commandsgen/commands.yml | 10 +++- temporalcloudcli/common.go | 60 +++++++++++++++++++++++ 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index bb8bb0d..ddd1d8f 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -121,6 +121,7 @@ type CloudNamespaceApplyCommand struct { DryRun bool AsyncOperationId string Idemptotent bool + Async bool } func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceApplyCommand { @@ -140,6 +141,7 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC 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) @@ -154,6 +156,7 @@ type CloudNamespaceEditCommand struct { Namespace string AsyncOperationId string Idemptotent bool + Async bool } func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceEditCommand { @@ -172,6 +175,7 @@ func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCo _ = 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) diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 756a450..80ffb40 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -65,7 +65,14 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) err return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) } - return cctx.Printer.PrintStructured(asyncOp, 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 { @@ -123,11 +130,14 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) er return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) } - // Step 8: Print async operation using PrintStructured - // The asyncOp is a proto message, so PrintStructured will handle it correctly - // This will output the async operation details including the operation ID - // We do NOT wait for the operation to complete - return cctx.Printer.PrintStructured(asyncOp, 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) } func (c *CloudNamespaceApplyCommand) performDryRun(cctx *CommandContext, client *namespaceClient, spec *namespace.NamespaceSpec) error { diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml index ac97e43..55b70ce 100644 --- a/temporalcloudcli/commandsgen/commands.yml +++ b/temporalcloudcli/commandsgen/commands.yml @@ -139,7 +139,7 @@ commands: tags: - namespaces - name: cloud namespace get - summary: Manage namespaces + summary: Get a namespace description: | Get a namespace from temporal cloud. has-init: false @@ -194,6 +194,10 @@ commands: 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: | @@ -220,3 +224,7 @@ commands: 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/common.go b/temporalcloudcli/common.go index 8d9185b..62cd477 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -8,12 +8,17 @@ import ( "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 { @@ -110,3 +115,58 @@ func runEditor(existing []byte) ([]byte, error) { } 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{}) + } + } + } +} From 58e7227e7e3e5be3f6f025d51ec269131a293ff0 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Thu, 4 Dec 2025 15:30:59 -0500 Subject: [PATCH 04/23] add some comments and updates --- go.mod | 12 +------- go.sum | 38 ----------------------- temporalcloudcli/commands.namespace.go | 2 ++ temporalcloudcli/common.go | 2 ++ temporalcloudcli/namespace.go | 11 ++++--- temporalcloudcli/payload.go | 42 -------------------------- temporalcloudcli/sqlite_test.go | 24 --------------- temporalcloudcli/timestamp.go | 26 ---------------- 8 files changed, 11 insertions(+), 146 deletions(-) delete mode 100644 temporalcloudcli/payload.go delete mode 100644 temporalcloudcli/sqlite_test.go delete mode 100644 temporalcloudcli/timestamp.go diff --git a/go.mod b/go.mod index 897164f..7a942c8 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( golang.org/x/term v0.32.0 google.golang.org/grpc v1.72.2 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.34.1 ) require ( @@ -34,13 +33,10 @@ require ( 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/hashicorp/golang-lru/v2 v2.0.7 // 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/ncruces/go-strftime v0.1.9 // indirect github.com/nexus-rpc/sdk-go v0.3.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 @@ -52,16 +48,10 @@ require ( 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 - modernc.org/gc/v3 v3.0.0 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.9.1 // indirect - modernc.org/strutil v1.2.1 // indirect - modernc.org/token v1.1.0 // indirect ) require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc + 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 diff --git a/go.sum b/go.sum index 7b7e4e6..58fb3c6 100644 --- a/go.sum +++ b/go.sum @@ -27,16 +27,12 @@ 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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -56,16 +52,12 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= @@ -121,8 +113,6 @@ golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqj 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/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 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= @@ -163,8 +153,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn 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/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 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= @@ -182,29 +170,3 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN 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= -modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= -modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.20.4 h1:3pPOlMcblnu5CBU3w1BFtepwBnLezGjPYTH8xBeYZM8= -modernc.org/ccgo/v4 v4.20.4/go.mod h1:meYiLeaGpKQmHBw8roW4DXLkDvusG+MD7LJ/kYyAouU= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.0.0 h1:JNEAEd0e/lnR1nlJemLPwS44KfBLBp4SAvZEZFaxfYU= -modernc.org/gc/v3 v3.0.0/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= -modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= -modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 80ffb40..ad46bbf 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -53,6 +53,7 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) err return err } + // TODO: (gmankes) remove this -- clean up and make shareable if asyncOp == nil { // Nothing changed (idempotent case) result := struct { @@ -140,6 +141,7 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) er 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) diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index 62cd477..ee2facc 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -166,6 +166,8 @@ func pollAsyncOperation( 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/namespace.go b/temporalcloudcli/namespace.go index 7ca67cc..6f885ea 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -73,11 +73,6 @@ func (c *namespaceClient) updateNamespace(ctx context.Context, n *namespace.Name return res.AsyncOperation, nil } -type applyNamespaceParams struct { - asyncOperationID string - idempotent bool -} - 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, @@ -93,6 +88,11 @@ func (c *namespaceClient) createNamespace(ctx context.Context, n *namespace.Name 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()) @@ -118,6 +118,7 @@ func (c *namespaceClient) applyNamespace(ctx context.Context, n *namespace.Names 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 := "" diff --git a/temporalcloudcli/payload.go b/temporalcloudcli/payload.go deleted file mode 100644 index 06586a0..0000000 --- a/temporalcloudcli/payload.go +++ /dev/null @@ -1,42 +0,0 @@ -package temporalcloudcli - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "strings" - - "go.temporal.io/api/common/v1" -) - -// CreatePayloads creates API Payload objects from given data and metadata slices. -// If metadata has an entry at a data index, it is used, otherwise it uses the metadata entry at index 0. -func CreatePayloads(data [][]byte, metadata map[string][][]byte, isBase64 bool) (*common.Payloads, error) { - ret := &common.Payloads{Payloads: make([]*common.Payload, len(data))} - for i, in := range data { - var metadataForIndex = make(map[string][]byte, len(metadata)) - for k, vals := range metadata { - if len(vals) == 0 { - continue - } - v := vals[0] - if len(vals) > i { - v = vals[i] - } - // If it's JSON, validate it - if k == "encoding" && strings.HasPrefix(string(v), "json/") && !json.Valid(in) { - return nil, fmt.Errorf("input #%v is not valid JSON", i+1) - } - metadataForIndex[k] = v - } - // Decode base64 if base64'd (std encoding only for now) - if isBase64 { - var err error - if in, err = base64.StdEncoding.DecodeString(string(in)); err != nil { - return nil, fmt.Errorf("input #%v is not valid base64", i+1) - } - } - ret.Payloads[i] = &common.Payload{Data: in, Metadata: metadataForIndex} - } - return ret, nil -} diff --git a/temporalcloudcli/sqlite_test.go b/temporalcloudcli/sqlite_test.go deleted file mode 100644 index b90eac6..0000000 --- a/temporalcloudcli/sqlite_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package temporalcloudcli - -import ( - "os" - "strings" - "testing" - - _ "modernc.org/sqlite" -) - -// Pinning modernc.org/sqlite to this version until https://gitlab.com/cznic/sqlite/-/issues/196 is resolved -func TestSqliteVersion(t *testing.T) { - content, err := os.ReadFile("../go.mod") - if err != nil { - t.Fatalf("Failed to read go.mod: %v", err) - } - contentStr := string(content) - if !strings.Contains(contentStr, "modernc.org/sqlite v1.34.1") { - t.Errorf("go.mod missing dependency modernc.org/sqlite v1.34.1") - } - if !strings.Contains(contentStr, "modernc.org/libc v1.55.3") { - t.Errorf("go.mod missing dependency modernc.org/libc v1.55.3") - } -} diff --git a/temporalcloudcli/timestamp.go b/temporalcloudcli/timestamp.go deleted file mode 100644 index fabf6ab..0000000 --- a/temporalcloudcli/timestamp.go +++ /dev/null @@ -1,26 +0,0 @@ -package temporalcloudcli - -import "time" - -type Timestamp time.Time - -func (t Timestamp) Time() time.Time { - return time.Time(t) -} - -func (t *Timestamp) String() string { - return t.Time().Format(time.RFC3339) -} - -func (t *Timestamp) Set(s string) error { - p, err := time.Parse(time.RFC3339, s) - if err != nil { - return err - } - *t = Timestamp(p) - return nil -} - -func (t *Timestamp) Type() string { - return "timestamp" -} From 674b49e221e13000b2644a5fee9e35bbdfc659ab Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:57:45 -0800 Subject: [PATCH 05/23] Move to using oauth client in cliext --- Makefile | 4 +- go.mod | 39 ++--- go.sum | 109 +++++++------- temporalcloudcli/cloud.go | 4 +- temporalcloudcli/commands.gen.go | 20 +-- temporalcloudcli/commands.login.go | 112 +++++++++++++- temporalcloudcli/commandsgen/commands.yml | 30 ++-- temporalcloudcli/login.go | 63 -------- temporalcloudcli/oauth.go | 96 ------------ temporalcloudcli/token_config.go | 169 ---------------------- 10 files changed, 219 insertions(+), 427 deletions(-) delete mode 100644 temporalcloudcli/login.go delete mode 100644 temporalcloudcli/oauth.go delete mode 100644 temporalcloudcli/token_config.go diff --git a/Makefile b/Makefile index ec5f1b8..13388ed 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,7 @@ all: gen build -gen: temporalcloudcli/commands.gen.go - -temporalcloudcli/commands.gen.go: temporalcloudcli/commandsgen/commands.yml +gen: go run ./temporalcloudcli/internal/cmd/gen-commands build: diff --git a/go.mod b/go.mod index 7a942c8..5e1db82 100644 --- a/go.mod +++ b/go.mod @@ -6,24 +6,24 @@ 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/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/temporalio/cli v1.5.1 + github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 github.com/temporalio/ui-server/v2 v2.42.1 - go.temporal.io/api v1.57.0 + go.temporal.io/api v1.59.0 go.temporal.io/cloud-sdk v0.6.0 - go.temporal.io/sdk v1.37.0 + go.temporal.io/sdk v1.38.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 + golang.org/x/term v0.38.0 + google.golang.org/grpc v1.77.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/BurntSushi/toml v1.4.0 // indirect + github.com/BurntSushi/toml v1.5.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 @@ -31,23 +31,24 @@ require ( 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/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.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/nexus-rpc/sdk-go v0.5.1 // indirect github.com/robfig/cron v1.2.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // 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 + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect ) require ( @@ -56,6 +57,6 @@ require ( 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 + golang.org/x/sys v0.39.0 // indirect google.golang.org/protobuf v1.36.10 ) diff --git a/go.sum b/go.sum index 58fb3c6..08a9988 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.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= @@ -15,8 +15,8 @@ github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yi 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/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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= @@ -29,10 +29,10 @@ 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/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= 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= @@ -52,8 +52,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YFU= +github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= 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= @@ -63,17 +63,19 @@ github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfm 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/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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/cli/cliext v0.0.0-20251209225252-1d6c81a62357 h1:+pAuGYfeythnC2IHv9aLTXzOZUFBNGn9Xfm9SMTxVAU= +github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357/go.mod h1:l9ElhzlkqSMfO+Xl7Bj4cmaBrHglAVT6cHP4Z/5V/Kw= 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= @@ -83,33 +85,34 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ 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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.temporal.io/api v1.59.0 h1:QUpAju1KKs9xBfGSI0Uwdyg06k6dRCJH+Zm3G1Jc9Vk= +go.temporal.io/api v1.59.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 v1.38.0 h1:4Bok5LEdED7YKpsSjIa3dDqram5VOq+ydBf4pyx0Wo4= +go.temporal.io/sdk v1.38.0/go.mod h1:a+R2Ej28ObvHoILbHaxMyind7M6D+W0L7edt5UJF4SE= 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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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= @@ -118,16 +121,16 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-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= @@ -135,19 +138,19 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w 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/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.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/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 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/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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= @@ -157,12 +160,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= 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= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index b2eb279..907c3ce 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -9,11 +9,11 @@ func newCloudClient(cctx *CommandContext) (*cloudclient.Client, error) { if cctx.RootCommand.ApiKey != "" { opts.APIKey = cctx.RootCommand.ApiKey } else { - tokenConfig, err := loadTokenConfig(cctx) + ssoToken, err := loadSSOToken(cctx) if err != nil { return nil, err } - opts.APIKeyReader = tokenConfig + opts.APIKey = ssoToken } cloudClient, err := cloudclient.New(opts) diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index ddd1d8f..a655339 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -26,9 +26,6 @@ type CloudCommand struct { NoJsonShorthandPayloads bool CommandTimeout Duration ClientConnectTimeout Duration - Domain string - Audience string - ClientId string ConfigDir string DisablePopUp bool ApiKey string @@ -65,9 +62,6 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { 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.") @@ -76,8 +70,12 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { } type CloudLoginCommand struct { - Parent *CloudCommand - Command cobra.Command + Parent *CloudCommand + Command cobra.Command + Domain string + Audience string + ClientId string + Reset bool } func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLoginCommand { @@ -88,6 +86,10 @@ func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogi s.Command.Short = "Log into temporal cloud" s.Command.Long = "Log into temporal cloud." s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "The domain to log into.") + s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "Used for login.") + s.Command.Flags().StringVar(&s.ClientId, "client-id", "CKpwBvLaP1nScTHfNip3smJMkzXzJsur", "Used for login.") + s.Command.Flags().BoolVar(&s.Reset, "reset", false, "Reset stored login configuration.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -195,7 +197,7 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom s.Parent = parent s.Command.DisableFlagsInUseLine = true s.Command.Use = "get [flags]" - s.Command.Short = "Manage namespaces" + s.Command.Short = "Get a namespace" 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.") diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index 92d3435..3881e48 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -1,6 +1,112 @@ package temporalcloudcli -func (c *CloudLoginCommand) run(cctx *CommandContext, args []string) error { - _, err := login(cctx, nil) - return err +import ( + "fmt" + "net/url" + "reflect" + "strings" + + "github.com/temporalio/cli/cliext" +) + +func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { + var oauth cliext.OAuthConfig + // First load the config to see if we have an existing config + loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) + if err != nil { + return fmt.Errorf("failed to load profile: %w", err) + } + if loadProfileResult.Profile.OAuth != nil && + !reflect.DeepEqual(*loadProfileResult.Profile.OAuth, cliext.OAuthConfig{}) && + !c.Reset { + // Existing OAuth config found, use it as a base + oauth = *loadProfileResult.Profile.OAuth + } else { + var err error + oauth.OAuthClientConfig, err = c.generateClientConfig() + if err != nil { + return fmt.Errorf("failed to generate OAuth client config: %w", err) + } + } + + oauthClient, err := cliext.NewOAuthClient(oauth.OAuthClientConfig) + if err != nil { + return fmt.Errorf("failed to create OAuth client: %w", err) + } + + oauthToken, err := oauthClient.Login(cctx) + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + + oauth.OAuthToken = oauthToken + loadProfileResult.Profile.OAuth = &oauth + loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile + if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +func loadSSOToken(cctx *CommandContext) (string, error) { + loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) + if err != nil { + return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login`", err) + } + + // check if we have had a valid token in the past + if loadProfileResult.Profile == nil && loadProfileResult.Profile.OAuth == nil { + return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") + } + + oauthClient, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig) + if err != nil { + return "", fmt.Errorf("failed to create OAuth client: %w", err) + } + + token, err := oauthClient.Token(cctx, loadProfileResult.Profile.OAuth) + if err != nil { + return "", fmt.Errorf("failed to retrieve access token: %w, please run `temporal cloud login`", err) + } + loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile + if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { + return "", fmt.Errorf("failed to write config file: %w", err) + } + return token.AccessToken, nil +} + +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 (c *CloudLoginCommand) generateClientConfig() (cliext.OAuthClientConfig, error) { + domainURL, err := parseURL(c.Domain) + if err != nil { + return cliext.OAuthClientConfig{}, fmt.Errorf("failed to parse domain: %w", err) + } + + return cliext.OAuthClientConfig{ + ClientID: c.ClientId, + // AuthURL: domainURL.JoinPath("authorize").String(), + AuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + RequestParams: map[string]string{ + "audience": c.Audience, + }, + Scopes: []string{"openid", "profile", "user", "offline_access"}, + }, nil } diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml index 55b70ce..8f84997 100644 --- a/temporalcloudcli/commandsgen/commands.yml +++ b/temporalcloudcli/commandsgen/commands.yml @@ -95,17 +95,6 @@ commands: 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. @@ -126,6 +115,25 @@ commands: description-header: Login tags: - login + options: + - name: domain + type: string + hidden: true + description: The domain to log into. + default: login.tmprl-test.cloud + - name: audience + type: string + hidden: true + default: https://saas-api.tmprl-test.cloud + description: Used for login. + - name: client-id + type: string + hidden: true + default: CKpwBvLaP1nScTHfNip3smJMkzXzJsur + description: Used for login. + - name: reset + type: bool + description: Reset stored login configuration. - name: cloud namespace summary: Manage namespaces description: | diff --git a/temporalcloudcli/login.go b/temporalcloudcli/login.go deleted file mode 100644 index 571c8a0..0000000 --- a/temporalcloudcli/login.go +++ /dev/null @@ -1,63 +0,0 @@ -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/oauth.go b/temporalcloudcli/oauth.go deleted file mode 100644 index 2fc9f5f..0000000 --- a/temporalcloudcli/oauth.go +++ /dev/null @@ -1,96 +0,0 @@ -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/token_config.go b/temporalcloudcli/token_config.go deleted file mode 100644 index bb1e3cc..0000000 --- a/temporalcloudcli/token_config.go +++ /dev/null @@ -1,169 +0,0 @@ -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 -} From 4d1ab363d195f050be76dd2d2e630d062b55cdf4 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:07:46 -0800 Subject: [PATCH 06/23] Move to using cliext for command generation --- .gitignore | 3 +- Makefile | 4 +- cmd/{cloud-cli => temporal-cloud}/main.go | 0 temporalcloudcli/commands.gen.go | 126 ++++++++++++++++++ temporalcloudcli/duration.go | 30 ----- .../internal/cmd/gen-commands/main.go | 41 ------ .../internal/cmd/gen-docs/main.go | 50 ------- temporalcloudcli/strings.go | 124 ----------------- 8 files changed, 130 insertions(+), 248 deletions(-) rename cmd/{cloud-cli => temporal-cloud}/main.go (100%) delete mode 100644 temporalcloudcli/duration.go delete mode 100644 temporalcloudcli/internal/cmd/gen-commands/main.go delete mode 100644 temporalcloudcli/internal/cmd/gen-docs/main.go delete mode 100644 temporalcloudcli/strings.go diff --git a/.gitignore b/.gitignore index e28278c..cf40acf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.so *.dylib /cloud-cli +/temporal-cloud # Test binary, built with `go test -c` *.test @@ -34,4 +35,4 @@ go.work.sum # bots and other local information .local/ -.claude/ \ No newline at end of file +.claude/ diff --git a/Makefile b/Makefile index 13388ed..f035f01 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ all: gen build gen: - go run ./temporalcloudcli/internal/cmd/gen-commands + go run github.com/temporalio/cli/cmd/gen-commands@main -input ./temporalcloudcli/commandsgen/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go build: - go build ./cmd/cloud-cli + go build ./cmd/temporal-cloud diff --git a/cmd/cloud-cli/main.go b/cmd/temporal-cloud/main.go similarity index 100% rename from cmd/cloud-cli/main.go rename to cmd/temporal-cloud/main.go diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index a655339..05f779d 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -3,11 +3,21 @@ package temporalcloudcli import ( + "fmt" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "os" + + "regexp" + + "strconv" + + "strings" + + "time" ) var hasHighlighting = isatty.IsTerminal(os.Stdout.Fd()) @@ -209,3 +219,119 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom } return &s } + +var reDays = regexp.MustCompile(`(\d+(\.\d*)?|(\.\d+))d`) + +type Duration time.Duration + +// ParseDuration is like time.ParseDuration, but supports unit "d" for days +// (always interpreted as exactly 24 hours). +func ParseDuration(s string) (time.Duration, error) { + s = reDays.ReplaceAllStringFunc(s, func(v string) string { + fv, err := strconv.ParseFloat(strings.TrimSuffix(v, "d"), 64) + if err != nil { + return v // will cause time.ParseDuration to return an error + } + return fmt.Sprintf("%fh", 24*fv) + }) + return time.ParseDuration(s) +} + +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 := ParseDuration(s) + if err != nil { + return err + } + *d = Duration(p) + return nil +} + +func (d *Duration) Type() string { + return "duration" +} + +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 { + Allowed map[string]string + Values []string +} + +func NewStringEnumArray(allowed []string, values []string) StringEnumArray { + 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" } + +type Timestamp time.Time + +func (t Timestamp) Time() time.Time { + return time.Time(t) +} + +func (t *Timestamp) String() string { + return t.Time().Format(time.RFC3339) +} + +func (t *Timestamp) Set(s string) error { + p, err := time.Parse(time.RFC3339, s) + if err != nil { + return err + } + *t = Timestamp(p) + return nil +} + +func (t *Timestamp) Type() string { + return "timestamp" +} diff --git a/temporalcloudcli/duration.go b/temporalcloudcli/duration.go deleted file mode 100644 index ea00fcb..0000000 --- a/temporalcloudcli/duration.go +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index a5ca006..0000000 --- a/temporalcloudcli/internal/cmd/gen-commands/main.go +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 94ff20b..0000000 --- a/temporalcloudcli/internal/cmd/gen-docs/main.go +++ /dev/null @@ -1,50 +0,0 @@ -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/strings.go b/temporalcloudcli/strings.go deleted file mode 100644 index 77f986f..0000000 --- a/temporalcloudcli/strings.go +++ /dev/null @@ -1,124 +0,0 @@ -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 -} From 5755a4c2037e8a26294868d664cccaae40912c90 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:14:29 -0800 Subject: [PATCH 07/23] cleanup some unreachable code --- temporalcloudcli/commandsgen/code.go | 441 -------------------------- temporalcloudcli/commandsgen/docs.go | 192 ----------- temporalcloudcli/commandsgen/parse.go | 249 --------------- 3 files changed, 882 deletions(-) delete mode 100644 temporalcloudcli/commandsgen/code.go delete mode 100644 temporalcloudcli/commandsgen/docs.go delete mode 100644 temporalcloudcli/commandsgen/parse.go diff --git a/temporalcloudcli/commandsgen/code.go b/temporalcloudcli/commandsgen/code.go deleted file mode 100644 index 6c67082..0000000 --- a/temporalcloudcli/commandsgen/code.go +++ /dev/null @@ -1,441 +0,0 @@ -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/docs.go b/temporalcloudcli/commandsgen/docs.go deleted file mode 100644 index 2cd912a..0000000 --- a/temporalcloudcli/commandsgen/docs.go +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 7945e1c..0000000 --- a/temporalcloudcli/commandsgen/parse.go +++ /dev/null @@ -1,249 +0,0 @@ -// 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 -} From 1194d8039cafb6e8a1603996f6b3fddfb27b3546 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:19:00 -0800 Subject: [PATCH 08/23] fix idempotent spelling typo --- temporalcloudcli/commands.gen.go | 8 ++++---- temporalcloudcli/commands.namespace.go | 4 ++-- temporalcloudcli/commandsgen/commands.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 05f779d..60dc852 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -132,7 +132,7 @@ type CloudNamespaceApplyCommand struct { Spec string DryRun bool AsyncOperationId string - Idemptotent bool + Idempotent bool Async bool } @@ -152,7 +152,7 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC _ = 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.Idempotent, "idempotent", "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 { @@ -167,7 +167,7 @@ type CloudNamespaceEditCommand struct { Command cobra.Command Namespace string AsyncOperationId string - Idemptotent bool + Idempotent bool Async bool } @@ -186,7 +186,7 @@ func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCo 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.Idempotent, "idempotent", "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 { diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index ad46bbf..7c25c73 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -47,7 +47,7 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) err asyncOperationID: c.AsyncOperationId, resourceVersion: ns.ResourceVersion, namespace: c.Namespace, - idempotent: c.Idemptotent, + idempotent: c.Idempotent, }) if err != nil { return err @@ -110,7 +110,7 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) er // 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 + idempotent: c.Idempotent, // Use the flag value } asyncOp, err := client.applyNamespace(cctx.Context, spec, params) diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml index 8f84997..7c57a3f 100644 --- a/temporalcloudcli/commandsgen/commands.yml +++ b/temporalcloudcli/commandsgen/commands.yml @@ -198,7 +198,7 @@ commands: type: string short: a description: The async operation id to use for the request, optional. - - name: idemptotent + - name: idempotent type: bool short: i description: Determines whether the command should error if there's nothing that has changed. @@ -228,7 +228,7 @@ commands: type: string short: a description: The async operation id to use for the request, optional. - - name: idemptotent + - name: idempotent type: bool short: i description: Determines whether the command should error if there's nothing that has changed. From 801204ed27b581e47c9f140d60b65b7116d71ee3 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:20:16 -0800 Subject: [PATCH 09/23] Address copilot code review feedback --- temporalcloudcli/commands.login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index 3881e48..4d0c71c 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -55,7 +55,7 @@ func loadSSOToken(cctx *CommandContext) (string, error) { } // check if we have had a valid token in the past - if loadProfileResult.Profile == nil && loadProfileResult.Profile.OAuth == nil { + if loadProfileResult.Profile == nil || loadProfileResult.Profile.OAuth == nil { return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") } From df5947685d9a5121a6bdbe2b7a70e3527c96b1ed Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:32:31 -0800 Subject: [PATCH 10/23] go mod cleanup --- Makefile | 2 +- go.mod | 8 +++++--- go.sum | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index f035f01..fc97fa9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ all: gen build gen: - go run github.com/temporalio/cli/cmd/gen-commands@main -input ./temporalcloudcli/commandsgen/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go + go tool gen-commands -input ./temporalcloudcli/commandsgen/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go build: go build ./cmd/temporal-cloud diff --git a/go.mod b/go.mod index 5e1db82..fccb951 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/temporalio/cli v1.5.1 github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 github.com/temporalio/ui-server/v2 v2.42.1 go.temporal.io/api v1.59.0 @@ -19,7 +18,6 @@ require ( go.temporal.io/server v1.29.1 golang.org/x/term v0.38.0 google.golang.org/grpc v1.77.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -39,6 +37,7 @@ require ( github.com/nexus-rpc/sdk-go v0.5.1 // indirect github.com/robfig/cron v1.2.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.46.0 // indirect @@ -49,6 +48,7 @@ require ( golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( @@ -58,5 +58,7 @@ require ( 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.39.0 // indirect - google.golang.org/protobuf v1.36.10 + google.golang.org/protobuf v1.36.11 ) + +tool github.com/temporalio/cli/cmd/gen-commands diff --git a/go.sum b/go.sum index 08a9988..e3b7473 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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/cli v1.5.2-0.20251212213638-36bff7182259 h1:HB3j4k3hgg9fINQhIOnR5PDlbrHeuVFLuExDIs5RlOk= +github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259/go.mod h1:8lfULNGZ1Y2sobXSpY+2qQ4vgR4hYLuTLpqnCIl9Au4= github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 h1:+pAuGYfeythnC2IHv9aLTXzOZUFBNGn9Xfm9SMTxVAU= github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357/go.mod h1:l9ElhzlkqSMfO+Xl7Bj4cmaBrHglAVT6cHP4Z/5V/Kw= github.com/temporalio/ui-server/v2 v2.42.1 h1:ajeOxqCnUiCRQQhQYLxaT7wUgF/slqZJtdW4pLjVqCs= @@ -168,8 +168,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= From 85f766aacbe9d3b9529176f40ea65e5c04c69215 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:38:23 -0800 Subject: [PATCH 11/23] Add a github action to build the cli --- .github/workflows/build.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ff1251c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: Check Generated Files +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + verify-generation: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Run commands generation command + run: make gen + - name: Verify no changes were generated + run: | + # Check if the working directory is dirty + if git diff-index --quiet HEAD --; then + echo "::notice::Generated files are up to date. No changes detected." + else + echo "::error::Detected uncommitted changes after running the generation command. Please run the generator and commit the changes." + git status + git diff + exit 1 + fi + build: + runs-on: ubuntu-latest + needs: verify-generation + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Build the cli binary + run: make build From 1a7535ef1d6d0aa55b9d24ad3716de2e983ce82b Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:43:54 -0800 Subject: [PATCH 12/23] Fix for github actions verifying generation --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff1251c..5ce74ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: run: make gen - name: Verify no changes were generated run: | - # Check if the working directory is dirty - if git diff-index --quiet HEAD --; then + # Check if there are any uncommitted changes in the working directory + if [ -z "$(git status --porcelain)" ]; then echo "::notice::Generated files are up to date. No changes detected." else echo "::error::Detected uncommitted changes after running the generation command. Please run the generator and commit the changes." From c7c9b0a2858f347e29ce5394f4d86848e53abeca Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:59:41 -0800 Subject: [PATCH 13/23] Add a --server flag --- temporalcloudcli/cloud.go | 3 +++ temporalcloudcli/commands.gen.go | 4 +++- temporalcloudcli/commandsgen/commands.yml | 8 +++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index 907c3ce..075eccc 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -6,6 +6,9 @@ import ( func newCloudClient(cctx *CommandContext) (*cloudclient.Client, error) { opts := cloudclient.Options{} + if cctx.RootCommand.Server != "" { + opts.HostPort = cctx.RootCommand.Server + } if cctx.RootCommand.ApiKey != "" { opts.APIKey = cctx.RootCommand.ApiKey } else { diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 60dc852..d624c93 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -39,6 +39,7 @@ type CloudCommand struct { ConfigDir string DisablePopUp bool ApiKey string + Server string } func NewCloudCommand(cctx *CommandContext) *CloudCommand { @@ -58,7 +59,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { 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.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Log level. Default is \"info\". 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") @@ -75,6 +76,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { 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.Command.PersistentFlags().StringVar(&s.Server, "server", "", "The temporal cloud ops api server address to use.") s.initCommand(cctx) return &s } diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml index 7c57a3f..a332dc7 100644 --- a/temporalcloudcli/commandsgen/commands.yml +++ b/temporalcloudcli/commandsgen/commands.yml @@ -45,9 +45,7 @@ commands: - warn - error - never - description: | - Log level. - Default is "info" for most commands and "warn" for `server start-dev`. + description: Log level. Default is "info". default: info - name: log-format type: string-enum @@ -104,6 +102,10 @@ commands: - name: api-key type: string description: The api key to use for auth. + - name: server + type: string + description: The temporal cloud ops api server address to use. + hidden: true - name: cloud login summary: Log into temporal cloud description: | From 59d4b7dfb5c0bc696598290674f916a5cf6836f7 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Sat, 13 Dec 2025 08:41:08 -0800 Subject: [PATCH 14/23] More changes --- Makefile | 2 +- go.mod | 1 + go.sum | 2 + temporalcloudcli/cloud.go | 40 +- temporalcloudcli/commands.gen.go | 133 ++++--- temporalcloudcli/commands.login.go | 27 -- temporalcloudcli/commands.namespace.go | 92 ++--- temporalcloudcli/commands.yml | 366 +++++++++++++++++++ temporalcloudcli/commandsgen/commands.yml | 240 ------------ temporalcloudcli/internal/printer/printer.go | 46 +++ temporalcloudcli/namespace.go | 33 +- 11 files changed, 577 insertions(+), 405 deletions(-) create mode 100644 temporalcloudcli/commands.yml delete mode 100644 temporalcloudcli/commandsgen/commands.yml diff --git a/Makefile b/Makefile index fc97fa9..2c1ac64 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ all: gen build gen: - go tool gen-commands -input ./temporalcloudcli/commandsgen/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go + go tool gen-commands -input ./temporalcloudcli/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go build: go build ./cmd/temporal-cloud diff --git a/go.mod b/go.mod index fccb951..99fd3a4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.3 require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 + github.com/kylelemons/godebug v1.1.0 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index e3b7473..d20ab69 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index 075eccc..5ce4b40 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -1,28 +1,56 @@ package temporalcloudcli import ( + "context" + "fmt" + + "github.com/temporalio/cli/cliext" "go.temporal.io/cloud-sdk/cloudclient" ) +func (c *CloudCommand) GetAPIKey(ctx context.Context) (string, error) { + loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) + if err != nil { + return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login`", err) + } + + // check if we have had a valid token in the past + if loadProfileResult.Profile == nil || loadProfileResult.Profile.OAuth == nil { + return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") + } + + oauthClient, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig) + if err != nil { + return "", fmt.Errorf("failed to create OAuth client: %w", err) + } + + token, err := oauthClient.Token(ctx, loadProfileResult.Profile.OAuth) + if err != nil { + return "", fmt.Errorf("failed to retrieve access token: %w, please run `temporal cloud login`", err) + } + loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile + if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { + return "", fmt.Errorf("failed to write config file: %w", err) + } + return token.AccessToken, nil +} + func newCloudClient(cctx *CommandContext) (*cloudclient.Client, error) { opts := cloudclient.Options{} if cctx.RootCommand.Server != "" { opts.HostPort = cctx.RootCommand.Server } if cctx.RootCommand.ApiKey != "" { + // an explicit api key was provided, use it opts.APIKey = cctx.RootCommand.ApiKey } else { - ssoToken, err := loadSSOToken(cctx) - if err != nil { - return nil, err - } - opts.APIKey = ssoToken + // fallaback to the oauth based sso token provider + opts.APIKeyReader = cctx.RootCommand } 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 index d624c93..f947671 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -47,36 +47,36 @@ func NewCloudCommand(cctx *CommandContext) *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" + s.Command.Long = "The Temporal Cloud CLI provides commands for managing and operating Temporal Cloud resources,\nincluding namespaces, users, and account settings.\n\nExample:\n\n\x1b[1mcloud namespace get --namespace my-namespace.my-account\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.Long = "The Temporal Cloud CLI provides commands for managing and operating Temporal Cloud resources,\nincluding namespaces, users, and account settings.\n\nExample:\n\n```\ncloud namespace get --namespace my-namespace.my-account\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.Command.PersistentFlags().StringVar(&s.ConfigFile, "config-file", "", "Path to the TOML configuration file. Defaults to `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is `$HOME/.config` on Linux, `$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows. EXPERIMENTAL.") + s.Command.PersistentFlags().StringVar(&s.Profile, "profile", "", "Name of the configuration profile to use from the config file. Profiles allow you to maintain multiple sets of settings. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigFile, "disable-config-file", false, "Disable loading configuration from the config file. When set, only command-line flags and environment variables are used. EXPERIMENTAL.") + s.Command.PersistentFlags().BoolVar(&s.DisableConfigEnv, "disable-config-env", false, "Disable loading configuration from environment variables. When set, only command-line flags and the config file are used. EXPERIMENTAL.") s.LogLevel = NewStringEnum([]string{"debug", "info", "warn", "error", "never"}, "info") - s.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Log level. Default is \"info\". Accepted values: debug, info, warn, error, never.") + s.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Set the logging verbosity level. Use 'debug' for troubleshooting, 'never' to suppress all logs. 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.Command.PersistentFlags().Var(&s.LogFormat, "log-format", "Format for log output. Use 'json' for structured logging suitable for log aggregation systems. 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.Command.PersistentFlags().VarP(&s.Output, "output", "o", "Format for command output (excludes log messages). Use 'json' for scripting, 'jsonl' for streaming JSON, 'none' to suppress output. 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.Command.PersistentFlags().Var(&s.TimeFormat, "time-format", "Format for displaying timestamps. 'relative' shows human-readable durations (e.g., \"2 hours ago\"), 'iso' shows ISO 8601 format, 'raw' shows Unix timestamps. 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.Command.PersistentFlags().Var(&s.Color, "color", "Control colored output. 'auto' enables color when outputting to a terminal and disables it otherwise. Accepted values: always, never, auto.") + s.Command.PersistentFlags().BoolVar(&s.NoJsonShorthandPayloads, "no-json-shorthand-payloads", false, "Display payloads in their raw binary format instead of attempting to decode them as JSON. Useful when payloads contain non-JSON data.") s.CommandTimeout = 0 - s.Command.PersistentFlags().Var(&s.CommandTimeout, "command-timeout", "The command execution timeout. 0s means no timeout.") + s.Command.PersistentFlags().Var(&s.CommandTimeout, "command-timeout", "Maximum time to wait for a command to complete. Use '0s' for no timeout. Example: '30s', '5m'.") 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.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.Command.PersistentFlags().StringVar(&s.Server, "server", "", "The temporal cloud ops api server address to use.") + s.Command.PersistentFlags().Var(&s.ClientConnectTimeout, "client-connect-timeout", "Maximum time to wait when establishing a connection to Temporal Cloud. Use '0s' for no timeout. Example: '10s', '1m'.") + s.Command.PersistentFlags().StringVar(&s.ConfigDir, "config-dir", "", "Directory path where CLI configuration files are stored, including authentication tokens and settings.") + s.Command.PersistentFlags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods.") + s.Command.PersistentFlags().StringVar(&s.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") + s.Command.PersistentFlags().StringVar(&s.Server, "server", "", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") s.initCommand(cctx) return &s } @@ -95,13 +95,17 @@ func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogi 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.Short = "Authenticate with Temporal Cloud" + if hasHighlighting { + s.Command.Long = "Authenticate with Temporal Cloud using browser-based OAuth login.\n\nThis command opens your default browser to complete authentication. Once\nlogged in, your credentials are stored locally for subsequent commands.\n\nExample:\n\n\x1b[1mcloud login\x1b[0m\n\nFor headless environments, use --disable-pop-up and follow the printed URL." + } else { + s.Command.Long = "Authenticate with Temporal Cloud using browser-based OAuth login.\n\nThis command opens your default browser to complete authentication. Once\nlogged in, your credentials are stored locally for subsequent commands.\n\nExample:\n\n```\ncloud login\n```\n\nFor headless environments, use --disable-pop-up and follow the printed URL." + } s.Command.Args = cobra.NoArgs - s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "The domain to log into.") - s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "Used for login.") - s.Command.Flags().StringVar(&s.ClientId, "client-id", "CKpwBvLaP1nScTHfNip3smJMkzXzJsur", "Used for login.") - s.Command.Flags().BoolVar(&s.Reset, "reset", false, "Reset stored login configuration.") + s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "Authentication domain for the OAuth provider.") + s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "OAuth audience parameter for token generation.") + s.Command.Flags().StringVar(&s.ClientId, "client-id", "CKpwBvLaP1nScTHfNip3smJMkzXzJsur", "OAuth client identifier for authentication.") + s.Command.Flags().BoolVar(&s.Reset, "reset", false, "Clear stored login credentials and configuration, then re-authenticate. Use this if you need to switch accounts or fix authentication issues.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -119,10 +123,11 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud var s CloudNamespaceCommand s.Parent = parent s.Command.Use = "namespace" - s.Command.Short = "Manage namespaces" - s.Command.Long = "Commands for managing namespaces." + s.Command.Short = "Manage Temporal Cloud namespaces" + s.Command.Long = "Commands for creating, updating, and managing Temporal Cloud namespaces.\n\nNamespaces provide isolation for workflows and activities. Each namespace\nhas its own configuration including retention period, region, and access\ncontrols." s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewCloudNamespaceApplyCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceDiffCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceEditCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceGetCommand(cctx, &s).Command) return &s @@ -131,8 +136,8 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud type CloudNamespaceApplyCommand struct { Parent *CloudNamespaceCommand Command cobra.Command + Namespace string Spec string - DryRun bool AsyncOperationId string Idempotent bool Async bool @@ -143,19 +148,53 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC s.Parent = parent s.Command.DisableFlagsInUseLine = true s.Command.Use = "apply [flags]" - s.Command.Short = "Create or update a namespace" + s.Command.Short = "Create or update a namespace from a specification" 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" + s.Command.Long = "Apply a namespace configuration to Temporal Cloud. Creates a new namespace\nif it doesn't exist, or updates an existing one to match the specification.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\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.Long = "Apply a namespace configuration to Temporal Cloud. Creates a new namespace\nif it doesn't exist, or updates an existing one to match the specification.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\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.") + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Flags().StringVarP(&s.Spec, "spec", "s", "", "Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. 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.Idempotent, "idempotent", "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.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") + s.Command.Flags().BoolVarP(&s.Idempotent, "idempotent", "i", false, "Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed.") + s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceDiffCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + Spec string + Verbose bool +} + +func NewCloudNamespaceDiffCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceDiffCommand { + var s CloudNamespaceDiffCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "diff [flags]" + s.Command.Short = "Show differences between current and specified namespace configuration" + if hasHighlighting { + s.Command.Long = "Compare the current configuration of a Temporal Cloud namespace with a\nprovided specification. Displays the differences without applying any\nchanges.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample with inline JSON:\n\n\x1b[1mcloud namespace diff --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\x1b[0m\n\nExample with file path:\n\n\x1b[1mcloud namespace diff --spec @namespace-spec.json\x1b[0m" + } else { + s.Command.Long = "Compare the current configuration of a Temporal Cloud namespace with a\nprovided specification. Displays the differences without applying any\nchanges.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample with inline JSON:\n\n```\ncloud namespace diff --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\n```\n\nExample with file path:\n\n```\ncloud namespace diff --spec @namespace-spec.json\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Flags().StringVarP(&s.Spec, "spec", "s", "", "Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "spec") + s.Command.Flags().BoolVarP(&s.Verbose, "verbose", "v", false, "Show detailed differences including unchanged fields. By default, only changed fields are shown.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -178,18 +217,18 @@ func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCo s.Parent = parent s.Command.DisableFlagsInUseLine = true s.Command.Use = "edit [flags]" - s.Command.Short = "Edit a namespace" + s.Command.Short = "Interactively edit a namespace configuration" 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" + s.Command.Long = "Open a namespace configuration in your default editor for interactive\nmodification. After saving and closing the editor, the changes are\napplied to Temporal Cloud.\n\nThe editor is determined by the EDITOR environment variable, falling\nback to 'vi' if not set.\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.Long = "Open a namespace configuration in your default editor for interactive\nmodification. After saving and closing the editor, the changes are\napplied to Temporal Cloud.\n\nThe editor is determined by the EDITOR environment variable, falling\nback to 'vi' if not set.\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.") + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., '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.Idempotent, "idempotent", "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.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") + s.Command.Flags().BoolVarP(&s.Idempotent, "idempotent", "i", false, "Succeed silently if no changes were made in the editor. Without this flag, the command errors when the configuration is unchanged.") + s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -209,10 +248,14 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom s.Parent = parent s.Command.DisableFlagsInUseLine = true s.Command.Use = "get [flags]" - s.Command.Short = "Get a namespace" - s.Command.Long = "Get a namespace from temporal cloud." + s.Command.Short = "Retrieve namespace details" + if hasHighlighting { + s.Command.Long = "Retrieve the configuration and status of a Temporal Cloud namespace.\n\nReturns details including region, retention period, endpoints, and\ncertificate information.\n\nExample:\n\n\x1b[1mcloud namespace get --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Retrieve the configuration and status of a Temporal Cloud namespace.\n\nReturns details including region, retention period, endpoints, and\ncertificate information.\n\nExample:\n\n```\ncloud namespace get --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.") + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., '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 { diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index 4d0c71c..0c041e5 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -48,33 +48,6 @@ func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { return nil } -func loadSSOToken(cctx *CommandContext) (string, error) { - loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) - if err != nil { - return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login`", err) - } - - // check if we have had a valid token in the past - if loadProfileResult.Profile == nil || loadProfileResult.Profile.OAuth == nil { - return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") - } - - oauthClient, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig) - if err != nil { - return "", fmt.Errorf("failed to create OAuth client: %w", err) - } - - token, err := oauthClient.Token(cctx, loadProfileResult.Profile.OAuth) - if err != nil { - return "", fmt.Errorf("failed to retrieve access token: %w, please run `temporal cloud login`", err) - } - loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile - if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { - return "", fmt.Errorf("failed to write config file: %w", err) - } - return token.AccessToken, nil -} - 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://") { diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 7c25c73..e173792 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -8,7 +8,7 @@ import ( "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) -func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, args []string) error { +func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []string) error { cloudClient, err := newCloudClient(cctx) if err != nil { return err @@ -24,7 +24,7 @@ func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, args []string) erro return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) } -func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) error { +func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error { cloudClient, err := newCloudClient(cctx) if err != nil { return err @@ -76,7 +76,7 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, args []string) err return pollAsyncOperation(cctx, asyncOp.Id) } -func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) error { +func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error { // Step 1: Load spec from file or inline specData, err := loadJSONSpec(c.Spec) if err != nil { @@ -89,36 +89,25 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) er 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 + // Step 3: 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) + // Step 4: Apply the namespace (create or update) params := applyNamespaceParams{ asyncOperationID: c.AsyncOperationId, // Use the flag value if provided idempotent: c.Idempotent, // Use the flag value } - asyncOp, err := client.applyNamespace(cctx.Context, spec, params) + asyncOp, err := client.applyNamespace(cctx.Context, c.Namespace, spec, params) if err != nil { return fmt.Errorf("failed to apply namespace: %w", err) } - // Step 7: Handle result + // Step 5: Handle result if asyncOp == nil { // Nothing changed (idempotent case) result := struct { @@ -131,55 +120,48 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, args []string) er return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) } - // Step 8: Handle async flag + // Step 6: Handle async flag if c.Async { // Return immediately with the async operation return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) } - // Step 9: Poll for completion + // Step 7: 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) +func (c *CloudNamespaceDiffCommand) run(cctx *CommandContext, _ []string) error { + // Step 1: Load spec from file or inline + specData, err := loadJSONSpec(c.Spec) 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] + // 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)) - // 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, + // Step 5: Retrieve existing namespace + existing, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err } - return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + // Step 6: Compute and print diff + return cctx.Printer.PrintDiff(existing.Spec, spec, printer.DiffOptions{ + Verbose: c.Verbose, + }) } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml new file mode 100644 index 0000000..edee903 --- /dev/null +++ b/temporalcloudcli/commands.yml @@ -0,0 +1,366 @@ +# Temporal Cloud CLI commands +commands: + - name: cloud + summary: Temporal Cloud command-line interface + description: | + The Temporal Cloud CLI provides commands for managing and operating Temporal Cloud resources, + including namespaces, users, and account settings. + + Example: + + ``` + cloud namespace get --namespace my-namespace.my-account + ``` + has-init: true + options: + - name: config-file + type: string + description: | + Path to the TOML configuration file. Defaults to + `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is + `$HOME/.config` on Linux, `$HOME/Library/Application Support` on + macOS, and `%AppData%` on Windows. + experimental: true + implied-env: TEMPORAL_CONFIG_FILE + - name: profile + type: string + description: | + Name of the configuration profile to use from the config file. + Profiles allow you to maintain multiple sets of settings. + experimental: true + implied-env: TEMPORAL_PROFILE + - name: disable-config-file + type: bool + description: | + Disable loading configuration from the config file. When set, only + command-line flags and environment variables are used. + experimental: true + - name: disable-config-env + type: bool + description: | + Disable loading configuration from environment variables. When set, + only command-line flags and the config file are used. + experimental: true + - name: log-level + type: string-enum + enum-values: + - debug + - info + - warn + - error + - never + description: | + Set the logging verbosity level. Use 'debug' for troubleshooting, + 'never' to suppress all logs. + default: info + - name: log-format + type: string-enum + description: | + Format for log output. Use 'json' for structured logging suitable + for log aggregation systems. + enum-values: + - text + - json + hidden-legacy-values: + - pretty + default: text + - name: output + type: string-enum + short: o + description: | + Format for command output (excludes log messages). Use 'json' for + scripting, 'jsonl' for streaming JSON, 'none' to suppress output. + enum-values: + - text + - json + - jsonl + - none + default: text + - name: time-format + type: string-enum + description: | + Format for displaying timestamps. 'relative' shows human-readable + durations (e.g., "2 hours ago"), 'iso' shows ISO 8601 format, + 'raw' shows Unix timestamps. + enum-values: + - relative + - iso + - raw + default: relative + - name: color + type: string-enum + description: | + Control colored output. 'auto' enables color when outputting to a + terminal and disables it otherwise. + enum-values: + - always + - never + - auto + default: auto + - name: no-json-shorthand-payloads + type: bool + description: | + Display payloads in their raw binary format instead of attempting + to decode them as JSON. Useful when payloads contain non-JSON data. + - name: command-timeout + type: duration + description: | + Maximum time to wait for a command to complete. Use '0s' for no + timeout. Example: '30s', '5m'. + - name: client-connect-timeout + type: duration + description: | + Maximum time to wait when establishing a connection to Temporal + Cloud. Use '0s' for no timeout. Example: '10s', '1m'. + - name: config-dir + type: string + description: | + Directory path where CLI configuration files are stored, including + authentication tokens and settings. + - name: disable-pop-up + type: bool + description: | + Prevent the CLI from opening a browser window during authentication. + Useful for headless environments or when using alternative auth methods. + - name: api-key + type: string + description: | + API key for authenticating with Temporal Cloud. Can be used instead + of interactive login for automation and CI/CD pipelines. + - name: server + type: string + description: | + Override the Temporal Cloud API server address. Used for connecting + to non-production environments. + hidden: true + - name: cloud login + summary: Authenticate with Temporal Cloud + description: | + Authenticate with Temporal Cloud using browser-based OAuth login. + + This command opens your default browser to complete authentication. Once + logged in, your credentials are stored locally for subsequent commands. + + Example: + + ``` + cloud login + ``` + + For headless environments, use --disable-pop-up and follow the printed URL. + has-init: false + docs: + keywords: + - login + - authentication + - auth + description-header: Login + tags: + - login + options: + - name: domain + type: string + hidden: true + description: | + Authentication domain for the OAuth provider. + default: login.tmprl-test.cloud + - name: audience + type: string + hidden: true + default: https://saas-api.tmprl-test.cloud + description: | + OAuth audience parameter for token generation. + - name: client-id + type: string + hidden: true + default: CKpwBvLaP1nScTHfNip3smJMkzXzJsur + description: | + OAuth client identifier for authentication. + - name: reset + type: bool + description: | + Clear stored login credentials and configuration, then re-authenticate. + Use this if you need to switch accounts or fix authentication issues. + - name: cloud namespace + summary: Manage Temporal Cloud namespaces + description: | + Commands for creating, updating, and managing Temporal Cloud namespaces. + + Namespaces provide isolation for workflows and activities. Each namespace + has its own configuration including retention period, region, and access + controls. + has-init: false + docs: + keywords: + - namespace + - management + description-header: Namespace Management Commands + tags: + - namespaces + - name: cloud namespace get + summary: Retrieve namespace details + description: | + Retrieve the configuration and status of a Temporal Cloud namespace. + + Returns details including region, retention period, endpoints, and + certificate information. + + Example: + + ``` + cloud namespace get --namespace my-namespace.my-account + ``` + has-init: false + docs: + keywords: + - namespace + - management + description-header: Retrieve a namespace. + tags: + - namespaces + options: + - name: namespace + required: true + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + - name: cloud namespace diff + summary: Show differences between current and specified namespace configuration + description: | + Compare the current configuration of a Temporal Cloud namespace with a + provided specification. Displays the differences without applying any + changes. + + The specification can be provided as inline JSON or loaded from a file + by prefixing the path with '@'. + + Example with inline JSON: + + ``` + cloud namespace diff --spec '{"name": "namespace-name", "region": "us-west-2", "retention_days": 7}' + ``` + + Example with file path: + + ``` + cloud namespace diff --spec @namespace-spec.json + ``` + has-init: false + options: + - name: namespace + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + required: true + - name: spec + type: string + description: | + Namespace configuration in JSON format. Provide inline JSON directly, + or use '@path/to/file.json' to load from a file. + short: s + required: true + - name: verbose + type: bool + short: v + description: | + Show detailed differences including unchanged fields. By default, only changed fields are shown. + - name: cloud namespace apply + summary: Create or update a namespace from a specification + description: | + Apply a namespace configuration to Temporal Cloud. Creates a new namespace + if it doesn't exist, or updates an existing one to match the specification. + + The specification can be provided as inline JSON or loaded from a file + by prefixing the path with '@'. + + 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: namespace + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + required: true + - name: spec + type: string + description: | + Namespace configuration in JSON format. Provide inline JSON directly, + or use '@path/to/file.json' to load from a file. + short: s + required: true + - name: async-operation-id + type: string + short: a + description: | + Custom identifier for tracking this async operation. If not provided, + a unique ID is generated automatically. + - name: idempotent + type: bool + short: i + description: | + Succeed silently if the namespace already matches the specification. + Without this flag, the command errors when no changes are needed. + - name: async + type: bool + short: c + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: cloud namespace edit + summary: Interactively edit a namespace configuration + description: | + Open a namespace configuration in your default editor for interactive + modification. After saving and closing the editor, the changes are + applied to Temporal Cloud. + + The editor is determined by the EDITOR environment variable, falling + back to 'vi' if not set. + + Example: + + ``` + cloud namespace edit --namespace my-namespace.my-account + ``` + has-init: false + options: + - name: namespace + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + required: true + - name: async-operation-id + type: string + short: a + description: | + Custom identifier for tracking this async operation. If not provided, + a unique ID is generated automatically. + - name: idempotent + type: bool + short: i + description: | + Succeed silently if no changes were made in the editor. Without this + flag, the command errors when the configuration is unchanged. + - name: async + type: bool + short: c + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. diff --git a/temporalcloudcli/commandsgen/commands.yml b/temporalcloudcli/commandsgen/commands.yml deleted file mode 100644 index a332dc7..0000000 --- a/temporalcloudcli/commandsgen/commands.yml +++ /dev/null @@ -1,240 +0,0 @@ -# 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". - 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: 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: server - type: string - description: The temporal cloud ops api server address to use. - hidden: true - - name: cloud login - summary: Log into temporal cloud - description: | - Log into temporal cloud. - has-init: false - docs: - keywords: - - login - description-header: Login - tags: - - login - options: - - name: domain - type: string - hidden: true - description: The domain to log into. - default: login.tmprl-test.cloud - - name: audience - type: string - hidden: true - default: https://saas-api.tmprl-test.cloud - description: Used for login. - - name: client-id - type: string - hidden: true - default: CKpwBvLaP1nScTHfNip3smJMkzXzJsur - description: Used for login. - - name: reset - type: bool - description: Reset stored login configuration. - - 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: idempotent - 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: idempotent - 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/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go index 87d2713..f32967d 100644 --- a/temporalcloudcli/internal/printer/printer.go +++ b/temporalcloudcli/internal/printer/printer.go @@ -12,6 +12,7 @@ import ( "time" "github.com/fatih/color" + "github.com/kylelemons/godebug/diff" "github.com/olekukonko/tablewriter" "go.temporal.io/api/common/v1" "go.temporal.io/api/temporalproto" @@ -609,3 +610,48 @@ func deriveColFromField(f reflect.StructField) *col { } return col } + +type DiffOptions struct { + // If true print all lines, otherwise only print changed lines + Verbose bool + // Disable color output + NoColor bool +} + +func (p *Printer) PrintDiff(a, b any, options DiffOptions) error { + if reflect.TypeOf(a) != reflect.TypeOf(b) { + return fmt.Errorf("cannot diff different types: %v vs %v", reflect.TypeOf(a), reflect.TypeOf(b)) + } + var atext, btext []byte + atext, err := p.jsonVal(a, " ", true) + if err != nil { + return fmt.Errorf("unable to convert a to text for diff: %w", err) + } + btext, err = p.jsonVal(b, " ", true) + if err != nil { + return fmt.Errorf("unable to convert b to text for diff: %w", err) + } + + diff := diff.Diff(string(atext), string(btext)) + for line := range strings.SplitSeq(diff, "\n") { + switch { + case strings.HasPrefix(line, "+"): + if options.NoColor { + p.writef("%s\n", line) + } else { + p.writef("%s\n", color.GreenString(line)) + } + case strings.HasPrefix(line, "-"): + if options.NoColor { + p.writef("%s\n", line) + } else { + p.writef("%s\n", color.RedString(line)) + } + case options.Verbose: + p.writef("%s\n", line) + default: + // Skip unchanged line + } + } + return nil +} diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index 6f885ea..9e10054 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -36,7 +36,6 @@ func (c *namespaceClient) getNamespace(ctx context.Context, namespace string) (* res, err := c.client.CloudService().GetNamespace(ctx, &cloudservice.GetNamespaceRequest{ Namespace: namespace, }) - if err != nil { return nil, err } @@ -93,19 +92,13 @@ type applyNamespaceParams struct { idempotent bool } -func (c *namespaceClient) applyNamespace(ctx context.Context, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { +func (c *namespaceClient) applyNamespace(ctx context.Context, namespace string, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { // Try to get the existing namespace - namespaces, err := c.listNamespacesWithName(ctx, n.GetName()) + existing, err := c.getNamespace(ctx, namespace) 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{ @@ -117,25 +110,3 @@ func (c *namespaceClient) applyNamespace(ctx context.Context, n *namespace.Names 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 -} From 56124da03692bac9539667fc9b0eb48e5b15aca7 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:00:01 -0800 Subject: [PATCH 15/23] Move to latest oauth cliext package --- go.mod | 7 ++++--- go.sum | 10 ++++++++++ temporalcloudcli/cloud.go | 19 ++++++++++--------- temporalcloudcli/commands.login.go | 14 +++++--------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 99fd3a4..1a80820 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 + github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc github.com/temporalio/ui-server/v2 v2.42.1 go.temporal.io/api v1.59.0 go.temporal.io/cloud-sdk v0.6.0 @@ -36,6 +36,7 @@ require ( github.com/labstack/echo/v4 v4.13.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/nexus-rpc/sdk-go v0.5.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/robfig/cron v1.2.0 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 // indirect @@ -47,8 +48,8 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d20ab69..492cda0 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,7 @@ 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 v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -58,6 +59,8 @@ github.com/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YF github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 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= @@ -78,6 +81,8 @@ github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 h1:HB3j4k3hgg9fIN github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259/go.mod h1:8lfULNGZ1Y2sobXSpY+2qQ4vgR4hYLuTLpqnCIl9Au4= github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 h1:+pAuGYfeythnC2IHv9aLTXzOZUFBNGn9Xfm9SMTxVAU= github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357/go.mod h1:l9ElhzlkqSMfO+Xl7Bj4cmaBrHglAVT6cHP4Z/5V/Kw= +github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc h1:6ODdtReKqUiuPSbK3F4Hh/GhpHpEsi8yuaEknMCUFqs= +github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc/go.mod h1:M/wKd+b3692lkER6MHdl5X4w21Waw8Km9Tj97vKzHR0= 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= @@ -139,6 +144,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -166,8 +172,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index 5ce4b40..7b735ed 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -19,18 +19,19 @@ func (c *CloudCommand) GetAPIKey(ctx context.Context) (string, error) { return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") } - oauthClient, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig) - if err != nil { - return "", fmt.Errorf("failed to create OAuth client: %w", err) - } - - token, err := oauthClient.Token(ctx, loadProfileResult.Profile.OAuth) + token, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig).Token(ctx, loadProfileResult.Profile.OAuth) if err != nil { return "", fmt.Errorf("failed to retrieve access token: %w, please run `temporal cloud login`", err) } - loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile - if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { - return "", fmt.Errorf("failed to write config file: %w", err) + if token.AccessTokenRefreshed { + token.AccessTokenRefreshed = false // reset the flag before saving + loadProfileResult.Profile.OAuth.OAuthToken = token + loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile + if err := cliext.WriteConfig(cliext.WriteConfigOptions{ + Config: loadProfileResult.Config, + }); err != nil { + return "", fmt.Errorf("failed to write config file: %w", err) + } } return token.AccessToken, nil } diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index 0c041e5..dad180b 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -29,12 +29,7 @@ func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { } } - oauthClient, err := cliext.NewOAuthClient(oauth.OAuthClientConfig) - if err != nil { - return fmt.Errorf("failed to create OAuth client: %w", err) - } - - oauthToken, err := oauthClient.Login(cctx) + oauthToken, err := cliext.NewOAuthClient(oauth.OAuthClientConfig).Login(cctx) if err != nil { return fmt.Errorf("failed to login: %w", err) } @@ -42,7 +37,9 @@ func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { oauth.OAuthToken = oauthToken loadProfileResult.Profile.OAuth = &oauth loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile - if err := cliext.WriteConfig(loadProfileResult.Config, ""); err != nil { + if err := cliext.WriteConfig(cliext.WriteConfigOptions{ + Config: loadProfileResult.Config, + }); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil @@ -74,8 +71,7 @@ func (c *CloudLoginCommand) generateClientConfig() (cliext.OAuthClientConfig, er return cliext.OAuthClientConfig{ ClientID: c.ClientId, - // AuthURL: domainURL.JoinPath("authorize").String(), - AuthURL: domainURL.JoinPath("oauth", "device", "code").String(), + AuthURL: domainURL.JoinPath("authorize").String(), TokenURL: domainURL.JoinPath("oauth", "token").String(), RequestParams: map[string]string{ "audience": c.Audience, From 0883a1f2bfd9a15730fbe8c1944a0170d7606461 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:26:19 -0800 Subject: [PATCH 16/23] more updats --- go.mod | 2 +- go.sum | 2 ++ temporalcloudcli/commands.gen.go | 2 ++ temporalcloudcli/commands.namespace.go | 3 +++ temporalcloudcli/commands.yml | 5 +++++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 1a80820..a772c14 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( ) require ( - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.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 diff --git a/go.sum b/go.sum index 492cda0..76ff0de 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.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= diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index f947671..3dc546d 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -241,6 +241,7 @@ type CloudNamespaceGetCommand struct { Parent *CloudNamespaceCommand Command cobra.Command Namespace string + Spec bool } func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceGetCommand { @@ -257,6 +258,7 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Flags().BoolVar(&s.Spec, "spec", false, "Output only the namespace specification in JSON format, omitting metadata and status information.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index e173792..bfa80ab 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -21,6 +21,9 @@ func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []string) error { return err } + if c.SpecOnly { + return cctx.Printer.PrintStructured(n.Spec, printer.StructuredOptions{}) + } return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index edee903..103337e 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -226,6 +226,11 @@ commands: The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). short: n + - name: spec + type: bool + description: | + Output only the namespace specification in JSON format, omitting + metadata and status information. - name: cloud namespace diff summary: Show differences between current and specified namespace configuration description: | From c5f1302c94acaf95f55bf309c418bf1e911c1202 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:39:45 -0800 Subject: [PATCH 17/23] Add guidelines for ai-agents --- AGENTS.MD | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 131 insertions(+) create mode 100644 AGENTS.MD create mode 120000 CLAUDE.md diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..347b180 --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,130 @@ +# Agents + +## Overview + +This repository contains the CLI Plugin for Temporal Cloud (`temporal-cloud`). + +## Project Structure + +- `cmd/temporal-cloud/` - Main entry point for the CLI +- `temporalcloudcli/` - Core CLI implementation + - `cloud.go` - The cloud specific client implementation including auth and clients for cloud ops api service + - `commands.go` - The command context thats passed around and the root command implementation + - `commands.gen.go` - Generated command code, do not edit + - `commands.login.go` - Login command implementation + - `commands.namespace.go` - Namespace command implementation + - `commands.yml` - Command configuration, used to generate commands.gen.go + - `common.go` - Shared utilities, constants, and types used across the CLI + - `namespace.go` - The namespace client implementation + - `internal/printer/` - Output formatting utilities + +## Building + +```bash +make +``` + +## Usage + +The plugin is meant to be an extension to the Temporal CLI. After building, the plugin binary should be copied to some place in your `PATH` and renamed to `temporal-cloud`. After that, you can use it as follows: + +```bash +temporal cloud [flags] +``` + + +### Use Anchor Comments +Add specially formatted comments throughout the codebase, where appropriate, for yourself as inline knowledge that can be easily `grep`ped for. + +- Use `AIDEV-NOTE:`, `AIDEV-TODO:`, or `AIDEV-QUESTION:` (all-caps prefix) for comments aimed at AI and developers. +- **Important:** Before scanning files, always first try to **grep for existing anchors** `AIDEV-*` in relevant subdirectories. +- **Update relevant anchors** when modifying associated code. +- **Do not remove `AIDEV-NOTE`s** without explicit human instruction. +- Make sure to add relevant anchor comments, whenever a file or piece of code is: + * too complex, or + * very important, or + * confusing, or + * could have a bug + +### General Code Style Guide +- Keep all `const`, `type`, and `var` at the top of the file. +- Group multiple `const`, `type` and `var` declarations together. e.g. +```go +type ( + CompanyName string + CompanyID string +) +``` + +- Limit lines to 120 characters. +- Avoid stuttering in names. When a name's context (like the enclosing type or package) already implies part of the name, do not repeat it. + +**Bad:** +```go +type User struct { + UserName string + UserID int +} + +func (u User) GetUserName() string { + return u.UserName +} +``` + +**Good:** +```go +type User struct { + Name string + ID int +} + +func (u User) GetName() string { + return u.Name +} +``` + +- Add an EOF newline for new files you create. + +## AI Communication Guidelines + +### Plan Before Implementing +Before providing your final implementation, use tags to: + +1. Break down the feature into smaller, manageable tasks. +2. Consider potential challenges for each task and how to address them. +3. Provide a high-level outline of the code structure, including function names and their purposes. +4. List specific test cases you plan to implement. +5. State which error handling approaches you will use for different scenarios. +6. Discuss the trade-offs inherent in your design decisions, including: + * Performance trade-offs + * Scalability trade-offs + * Complexity trade-offs + * Security trade-offs +7. Reason about the failure modes of your design. How does it handle crashes? A 10x increase in load? + +### Code Explanation Format +When explaining code mechanics with verb constructions (e.g., "calls", "sends", "is processed"), follow this format: +1. **Bold** the verb in the sentence +2. Immediately after the sentence, provide a clickable code citation showing the exact line where that action occurs +3. Use the format: `[filepath:line](filepath:line)` for clickable code citations + +**Example:** +When the account entity module initializes, it **registers** activities with the Temporal worker using prefixed names: + +[entities/account/internal/fx.go:22](entities/account/internal/fx.go:22) +```go +w.RegisterActivityWithOptions(a, activity.RegisterOptions{Name: ActivityNamePrefix}) +``` + +## What AI _Must_ Do + +1. Use environment variables instead of committing secrets +2. Always ask instead of assuming business logic +3. Add to AIDEV comments or ask instead of removing them +4. Stay focused on the task at hand. When in doubt about whether a change is related to the task, ask. + +## Development Process +1. Follow the style guide rules above. +2. When a new `.go` file is created, git add it. +3. Before saying that you're "done", ensure the following checks pass: + - `make all` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..454567d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.MD \ No newline at end of file From 5a3b687ff2daaa55b9d246617fe32a5acfdd348c Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:17:00 -0800 Subject: [PATCH 18/23] more updates --- go.mod | 2 +- go.sum | 2 + temporalcloudcli/cloud.go | 26 +++-- temporalcloudcli/commands.gen.go | 32 +++++- temporalcloudcli/commands.login.go | 67 ++++++++---- temporalcloudcli/commands.namespace.go | 2 +- temporalcloudcli/commands.yml | 30 ++++- temporalcloudcli/oauth.go | 146 +++++++++++++++++++++++++ 8 files changed, 270 insertions(+), 37 deletions(-) create mode 100644 temporalcloudcli/oauth.go diff --git a/go.mod b/go.mod index a772c14..ffff73f 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc + github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390 github.com/temporalio/ui-server/v2 v2.42.1 go.temporal.io/api v1.59.0 go.temporal.io/cloud-sdk v0.6.0 diff --git a/go.sum b/go.sum index 76ff0de..662d1ca 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 h1:+pAuGYfey github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357/go.mod h1:l9ElhzlkqSMfO+Xl7Bj4cmaBrHglAVT6cHP4Z/5V/Kw= github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc h1:6ODdtReKqUiuPSbK3F4Hh/GhpHpEsi8yuaEknMCUFqs= github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc/go.mod h1:M/wKd+b3692lkER6MHdl5X4w21Waw8Km9Tj97vKzHR0= +github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390 h1:JPBxHfsAeJFy5uBrdNAe1GScQwcG8RzukLsq3O9QH8o= +github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390/go.mod h1:A9EHuWgszyY1o70CwnJXLkgqfoGKKHXjkKlpE2fj3Uc= 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= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index 7b735ed..c9029e4 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -2,6 +2,7 @@ package temporalcloudcli import ( "context" + "errors" "fmt" "github.com/temporalio/cli/cliext" @@ -9,26 +10,27 @@ import ( ) func (c *CloudCommand) GetAPIKey(ctx context.Context) (string, error) { - loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) + loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{}) if err != nil { - return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login`", err) + return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login --reset`", err) } // check if we have had a valid token in the past - if loadProfileResult.Profile == nil || loadProfileResult.Profile.OAuth == nil { - return "", fmt.Errorf("no login configurations found, please run `temporal cloud login`") + if loadClientOauthRes.OAuth == nil || loadClientOauthRes.OAuth.ClientConfig == nil { + return "", fmt.Errorf("no login session found, please run `temporal cloud login`") } - token, err := cliext.NewOAuthClient(loadProfileResult.Profile.OAuth.OAuthClientConfig).Token(ctx, loadProfileResult.Profile.OAuth) + token, refreshed, err := GetToken(ctx, loadClientOauthRes.OAuth.ClientConfig, loadClientOauthRes.OAuth.Token) if err != nil { - return "", fmt.Errorf("failed to retrieve access token: %w, please run `temporal cloud login`", err) + if errors.Is(err, ErrLoginRequired) { + return "", fmt.Errorf("login session expired, please run `temporal cloud login`", err) + } + return "", fmt.Errorf("failed to get access token: %w", err) } - if token.AccessTokenRefreshed { - token.AccessTokenRefreshed = false // reset the flag before saving - loadProfileResult.Profile.OAuth.OAuthToken = token - loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile - if err := cliext.WriteConfig(cliext.WriteConfigOptions{ - Config: loadProfileResult.Config, + if refreshed { + loadClientOauthRes.OAuth.Token = token + if err := cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ + OAuth: loadClientOauthRes.OAuth, }); err != nil { return "", fmt.Errorf("failed to write config file: %w", err) } diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 3dc546d..dfecfaa 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -53,6 +53,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { } s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewCloudLoginCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudLogoutCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceCommand(cctx, &s).Command) s.Command.PersistentFlags().StringVar(&s.ConfigFile, "config-file", "", "Path to the TOML configuration file. Defaults to `$CONFIG_PATH/temporal/temporal.toml` where `$CONFIG_PATH` is `$HOME/.config` on Linux, `$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows. EXPERIMENTAL.") s.Command.PersistentFlags().StringVar(&s.Profile, "profile", "", "Name of the configuration profile to use from the config file. Profiles allow you to maintain multiple sets of settings. EXPERIMENTAL.") @@ -76,7 +77,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { s.Command.PersistentFlags().StringVar(&s.ConfigDir, "config-dir", "", "Directory path where CLI configuration files are stored, including authentication tokens and settings.") s.Command.PersistentFlags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods.") s.Command.PersistentFlags().StringVar(&s.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") - s.Command.PersistentFlags().StringVar(&s.Server, "server", "", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") + s.Command.PersistentFlags().StringVar(&s.Server, "server", "saas-api.tmprl-test.cloud:443", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") s.initCommand(cctx) return &s } @@ -104,7 +105,7 @@ func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogi s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "Authentication domain for the OAuth provider.") s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "OAuth audience parameter for token generation.") - s.Command.Flags().StringVar(&s.ClientId, "client-id", "CKpwBvLaP1nScTHfNip3smJMkzXzJsur", "OAuth client identifier for authentication.") + s.Command.Flags().StringVar(&s.ClientId, "client-id", "XBimMwn90eAnjsiGVbAJ3Hgd9z06jjJB", "OAuth client identifier for authentication.") s.Command.Flags().BoolVar(&s.Reset, "reset", false, "Clear stored login credentials and configuration, then re-authenticate. Use this if you need to switch accounts or fix authentication issues.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { @@ -114,6 +115,33 @@ func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogi return &s } +type CloudLogoutCommand struct { + Parent *CloudCommand + Command cobra.Command + Domain string +} + +func NewCloudLogoutCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogoutCommand { + var s CloudLogoutCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "logout [flags]" + s.Command.Short = "Clear Temporal Cloud authentication credentials" + if hasHighlighting { + s.Command.Long = "Log out from Temporal Cloud by clearing stored authentication tokens\nand credentials from the local configuration.\n\nExample:\n\n\x1b[1mcloud logout\x1b[0m" + } else { + s.Command.Long = "Log out from Temporal Cloud by clearing stored authentication tokens\nand credentials from the local configuration.\n\nExample:\n\n```\ncloud logout\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "Authentication domain for the OAuth provider.") + 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 diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index dad180b..dccfcaa 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -6,39 +6,46 @@ import ( "reflect" "strings" + "github.com/pkg/browser" "github.com/temporalio/cli/cliext" + "golang.org/x/oauth2" +) + +const ( + // A redirectURI that points to a local server for OAuth callbacks. The port had been picked up arbitrarily. + // This url must be configured on auth0 client as allowed as a callback url. + redirectURI = "http://127.0.0.1:56628/callback" ) func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { - var oauth cliext.OAuthConfig + var oauthConfig cliext.OAuthConfig // First load the config to see if we have an existing config - loadProfileResult, err := cliext.LoadProfile(cliext.LoadProfileOptions{}) + loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{}) if err != nil { return fmt.Errorf("failed to load profile: %w", err) } - if loadProfileResult.Profile.OAuth != nil && - !reflect.DeepEqual(*loadProfileResult.Profile.OAuth, cliext.OAuthConfig{}) && + if loadClientOauthRes.OAuth != nil && + !reflect.DeepEqual(*loadClientOauthRes.OAuth, cliext.OAuthConfig{}) && + !reflect.DeepEqual(*loadClientOauthRes.OAuth.ClientConfig, oauth2.Config{}) && !c.Reset { // Existing OAuth config found, use it as a base - oauth = *loadProfileResult.Profile.OAuth + oauthConfig = *loadClientOauthRes.OAuth } else { var err error - oauth.OAuthClientConfig, err = c.generateClientConfig() + oauthConfig.ClientConfig, err = c.generateOauthClientConfig() if err != nil { return fmt.Errorf("failed to generate OAuth client config: %w", err) } } - oauthToken, err := cliext.NewOAuthClient(oauth.OAuthClientConfig).Login(cctx) + oauthToken, err := Login(cctx, oauthConfig.ClientConfig, oauth2.SetAuthURLParam("audience", c.Audience)) if err != nil { return fmt.Errorf("failed to login: %w", err) } - oauth.OAuthToken = oauthToken - loadProfileResult.Profile.OAuth = &oauth - loadProfileResult.Config.Profiles[loadProfileResult.ProfileName] = loadProfileResult.Profile - if err := cliext.WriteConfig(cliext.WriteConfigOptions{ - Config: loadProfileResult.Config, + oauthConfig.Token = oauthToken + if err := cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ + OAuth: &oauthConfig, }); err != nil { return fmt.Errorf("failed to write config file: %w", err) } @@ -63,19 +70,39 @@ func parseURL(s string) (*url.URL, error) { return u, err } -func (c *CloudLoginCommand) generateClientConfig() (cliext.OAuthClientConfig, error) { +func (c *CloudLoginCommand) generateOauthClientConfig() (*oauth2.Config, error) { domainURL, err := parseURL(c.Domain) if err != nil { - return cliext.OAuthClientConfig{}, fmt.Errorf("failed to parse domain: %w", err) + return nil, fmt.Errorf("failed to parse domain: %w", err) } - return cliext.OAuthClientConfig{ + return &oauth2.Config{ ClientID: c.ClientId, - AuthURL: domainURL.JoinPath("authorize").String(), - TokenURL: domainURL.JoinPath("oauth", "token").String(), - RequestParams: map[string]string{ - "audience": c.Audience, + Endpoint: oauth2.Endpoint{ + AuthURL: domainURL.JoinPath("authorize").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, }, - Scopes: []string{"openid", "profile", "user", "offline_access"}, + RedirectURL: redirectURI, + Scopes: []string{"openid", "profile", "user", "offline_access"}, }, nil } + +func (c *CloudLogoutCommand) run(cctx *CommandContext, _ []string) error { + domainURL, err := parseURL(c.Domain) + if err != nil { + return fmt.Errorf("failed to parse domain: %w", err) + } + + if err := cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ + OAuth: &cliext.OAuthConfig{}, + }); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + logoutURL := domainURL.JoinPath("v2", "logout") + fmt.Printf("Opening browser to logout. If it doesn't open, visit: %s\n", logoutURL.String()) + _ = browser.OpenURL(logoutURL.String()) + + return nil +} diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index bfa80ab..b131f23 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -21,7 +21,7 @@ func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []string) error { return err } - if c.SpecOnly { + if c.Spec { return cctx.Printer.PrintStructured(n.Spec, printer.StructuredOptions{}) } return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 103337e..8d50a66 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -133,6 +133,7 @@ commands: Override the Temporal Cloud API server address. Used for connecting to non-production environments. hidden: true + default: saas-api.tmprl-test.cloud:443 - name: cloud login summary: Authenticate with Temporal Cloud description: | @@ -173,7 +174,7 @@ commands: - name: client-id type: string hidden: true - default: CKpwBvLaP1nScTHfNip3smJMkzXzJsur + default: XBimMwn90eAnjsiGVbAJ3Hgd9z06jjJB description: | OAuth client identifier for authentication. - name: reset @@ -181,6 +182,33 @@ commands: description: | Clear stored login credentials and configuration, then re-authenticate. Use this if you need to switch accounts or fix authentication issues. + - name: cloud logout + summary: Clear Temporal Cloud authentication credentials + description: | + Log out from Temporal Cloud by clearing stored authentication tokens + and credentials from the local configuration. + + Example: + + ``` + cloud logout + ``` + has-init: false + docs: + keywords: + - logout + - authentication + - auth + description-header: Logout + tags: + - login + options: + - name: domain + type: string + hidden: true + description: | + Authentication domain for the OAuth provider. + default: login.tmprl-test.cloud - name: cloud namespace summary: Manage Temporal Cloud namespaces description: | diff --git a/temporalcloudcli/oauth.go b/temporalcloudcli/oauth.go new file mode 100644 index 0000000..4a877ae --- /dev/null +++ b/temporalcloudcli/oauth.go @@ -0,0 +1,146 @@ +package temporalcloudcli + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/pkg/browser" + "golang.org/x/oauth2" +) + +type oauthCallbackResult struct { + code string + err error +} + +func Login(ctx context.Context, config *oauth2.Config, options ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + // Generate PKCE challenge. + verifier := oauth2.GenerateVerifier() + options = append(options, oauth2.S256ChallengeOption(verifier)) + + // Generate random state for CSRF protection. + var stateBytes [16]byte + if _, err := rand.Read(stateBytes[:]); err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + state := base64.RawURLEncoding.EncodeToString(stateBytes[:]) + + authURL := config.AuthCodeURL(state, options...) + fmt.Printf("Opening browser to authorize. If it doesn't open, visit: %s\n", authURL) + _ = browser.OpenURL(authURL) + + // Parse redirect URL to get host and path + url, err := url.Parse(config.RedirectURL) + if err != nil { + return nil, fmt.Errorf("invalid redirect URL: %w", err) + } + + // Start HTTP server to handle callback. + var once sync.Once + resultCh := make(chan oauthCallbackResult, 1) + server := &http.Server{ + Addr: url.Host, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" { + http.NotFound(w, r) + return + } + + // Use sync.Once to only process the first callback. + once.Do(func() { + query := r.URL.Query() + + // Check for OAuth error response. + if errCode := query.Get("error"); errCode != "" { + errDesc := query.Get("error_description") + if errDesc == "" { + errDesc = errCode + } + resultCh <- oauthCallbackResult{err: fmt.Errorf("authorization failed: %s", errDesc)} + http.Error(w, fmt.Sprintf("Authorization failed: %s", errDesc), http.StatusBadRequest) + return + } + + // Validate state to prevent CSRF. + if query.Get("state") != state { + resultCh <- oauthCallbackResult{err: fmt.Errorf("invalid state parameter")} + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + // Check for authorization code. + code := query.Get("code") + if code == "" { + resultCh <- oauthCallbackResult{err: fmt.Errorf("missing authorization code")} + http.Error(w, "Missing authorization code", http.StatusBadRequest) + return + } + + resultCh <- oauthCallbackResult{code: code} + fmt.Fprint(w, "Authorization successful! You can close this window.") + }) + }), + } + go server.ListenAndServe() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + server.Shutdown(shutdownCtx) + }() + + // Wait for callback result or context cancellation. + var result oauthCallbackResult + select { + case result = <-resultCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + if result.err != nil { + return nil, result.err + } + + // Exchange code for token with PKCE verifier. + token, err := config.Exchange(ctx, result.code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + return token, nil +} + +var ErrLoginRequired = errors.New("login required") + +func GetToken(ctx context.Context, config *oauth2.Config, token *oauth2.Token) (*oauth2.Token, bool, error) { + if token != nil { + // Check if the token is still valid + if token.Valid() { + return token, false, nil + } + } + + tokenSource := config.TokenSource(ctx, token) + newToken, err := tokenSource.Token() + if err != nil { + if requiresLogin(err) { + return nil, false, ErrLoginRequired + } + return nil, false, fmt.Errorf("failed to refresh token: %w", err) + } + return newToken, true, nil +} + +// requiresLogin checks if the error indicates an invalid or expired refresh token. +func requiresLogin(err error) bool { + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "invalid_grant" { + return true + } + return false +} From 32346c19b6d8cd24372f8eba562894032397c794 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:20:56 -0800 Subject: [PATCH 19/23] More ux updates --- temporalcloudcli/commands.gen.go | 14 ++++++++------ temporalcloudcli/commands.login.go | 9 ++------- temporalcloudcli/commands.yml | 6 ++++++ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index dfecfaa..6ddedb4 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -83,12 +83,13 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { } type CloudLoginCommand struct { - Parent *CloudCommand - Command cobra.Command - Domain string - Audience string - ClientId string - Reset bool + Parent *CloudCommand + Command cobra.Command + Domain string + Audience string + ClientId string + RedirectUrl string + Reset bool } func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLoginCommand { @@ -106,6 +107,7 @@ func NewCloudLoginCommand(cctx *CommandContext, parent *CloudCommand) *CloudLogi s.Command.Flags().StringVar(&s.Domain, "domain", "login.tmprl-test.cloud", "Authentication domain for the OAuth provider.") s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "OAuth audience parameter for token generation.") s.Command.Flags().StringVar(&s.ClientId, "client-id", "XBimMwn90eAnjsiGVbAJ3Hgd9z06jjJB", "OAuth client identifier for authentication.") + s.Command.Flags().StringVar(&s.RedirectUrl, "redirect-url", "http://127.0.0.1:56628/callback", "Redirect URL for OAuth authentication flow.") s.Command.Flags().BoolVar(&s.Reset, "reset", false, "Clear stored login credentials and configuration, then re-authenticate. Use this if you need to switch accounts or fix authentication issues.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go index dccfcaa..2450a2c 100644 --- a/temporalcloudcli/commands.login.go +++ b/temporalcloudcli/commands.login.go @@ -11,12 +11,6 @@ import ( "golang.org/x/oauth2" ) -const ( - // A redirectURI that points to a local server for OAuth callbacks. The port had been picked up arbitrarily. - // This url must be configured on auth0 client as allowed as a callback url. - redirectURI = "http://127.0.0.1:56628/callback" -) - func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { var oauthConfig cliext.OAuthConfig // First load the config to see if we have an existing config @@ -49,6 +43,7 @@ func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { }); err != nil { return fmt.Errorf("failed to write config file: %w", err) } + fmt.Println("Login successful!") return nil } @@ -83,7 +78,7 @@ func (c *CloudLoginCommand) generateOauthClientConfig() (*oauth2.Config, error) TokenURL: domainURL.JoinPath("oauth", "token").String(), AuthStyle: oauth2.AuthStyleInParams, }, - RedirectURL: redirectURI, + RedirectURL: c.RedirectUrl, Scopes: []string{"openid", "profile", "user", "offline_access"}, }, nil } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 8d50a66..8df0396 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -177,6 +177,12 @@ commands: default: XBimMwn90eAnjsiGVbAJ3Hgd9z06jjJB description: | OAuth client identifier for authentication. + - name: redirect-url + type: string + hidden: true + default: http://127.0.0.1:56628/callback + description: | + Redirect URL for OAuth authentication flow. - name: reset type: bool description: | From 9788f409faf1d03a0400a03e4619c70c55994a52 Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:45:47 -0800 Subject: [PATCH 20/23] update mod update --- go.mod | 4 ++-- go.sum | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index ffff73f..04f5c5d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/fatih/color v1.18.0 github.com/kylelemons/godebug v1.1.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -17,6 +18,7 @@ require ( go.temporal.io/sdk v1.38.0 go.temporal.io/sdk/contrib/envconfig v0.1.0 go.temporal.io/server v1.29.1 + golang.org/x/oauth2 v0.34.0 golang.org/x/term v0.38.0 google.golang.org/grpc v1.77.0 ) @@ -36,7 +38,6 @@ require ( github.com/labstack/echo/v4 v4.13.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/nexus-rpc/sdk-go v0.5.1 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/robfig/cron v1.2.0 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 // indirect @@ -44,7 +45,6 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index 662d1ca..c39dd10 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -31,7 +29,6 @@ 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 v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -81,10 +78,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 h1:HB3j4k3hgg9fINQhIOnR5PDlbrHeuVFLuExDIs5RlOk= github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259/go.mod h1:8lfULNGZ1Y2sobXSpY+2qQ4vgR4hYLuTLpqnCIl9Au4= -github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357 h1:+pAuGYfeythnC2IHv9aLTXzOZUFBNGn9Xfm9SMTxVAU= -github.com/temporalio/cli/cliext v0.0.0-20251209225252-1d6c81a62357/go.mod h1:l9ElhzlkqSMfO+Xl7Bj4cmaBrHglAVT6cHP4Z/5V/Kw= -github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc h1:6ODdtReKqUiuPSbK3F4Hh/GhpHpEsi8yuaEknMCUFqs= -github.com/temporalio/cli/cliext v0.0.0-20251213031832-42855c9559bc/go.mod h1:M/wKd+b3692lkER6MHdl5X4w21Waw8Km9Tj97vKzHR0= github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390 h1:JPBxHfsAeJFy5uBrdNAe1GScQwcG8RzukLsq3O9QH8o= github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390/go.mod h1:A9EHuWgszyY1o70CwnJXLkgqfoGKKHXjkKlpE2fj3Uc= github.com/temporalio/ui-server/v2 v2.42.1 h1:ajeOxqCnUiCRQQhQYLxaT7wUgF/slqZJtdW4pLjVqCs= @@ -174,12 +167,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= From 2b2af9478c43657371ea8cdfc56d1db76297172c Mon Sep 17 00:00:00 2001 From: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:56:50 -0800 Subject: [PATCH 21/23] Add a patch command for namespace retention days --- go.mod | 10 +- go.sum | 20 ++-- temporalcloudcli/commands.gen.go | 107 ++++++++++++++--- temporalcloudcli/commands.namespace.go | 157 +++++++++++++++++++++---- temporalcloudcli/commands.yml | 157 ++++++++++++++++++------- temporalcloudcli/common.go | 8 ++ temporalcloudcli/namespace.go | 74 ++++++++++-- 7 files changed, 428 insertions(+), 105 deletions(-) diff --git a/go.mod b/go.mod index 04f5c5d..290591e 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390 + github.com/temporalio/cli/cliext v0.0.0-20251219224619-5b50c14ff5c9 github.com/temporalio/ui-server/v2 v2.42.1 go.temporal.io/api v1.59.0 go.temporal.io/cloud-sdk v0.6.0 @@ -20,7 +20,7 @@ require ( go.temporal.io/server v1.29.1 golang.org/x/oauth2 v0.34.0 golang.org/x/term v0.38.0 - google.golang.org/grpc v1.77.0 + google.golang.org/grpc v1.78.0 ) require ( @@ -33,7 +33,7 @@ require ( 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.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // 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 @@ -48,8 +48,8 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c39dd10..86cce20 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ 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.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= 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= @@ -78,8 +78,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259 h1:HB3j4k3hgg9fINQhIOnR5PDlbrHeuVFLuExDIs5RlOk= github.com/temporalio/cli v1.5.2-0.20251212213638-36bff7182259/go.mod h1:8lfULNGZ1Y2sobXSpY+2qQ4vgR4hYLuTLpqnCIl9Au4= -github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390 h1:JPBxHfsAeJFy5uBrdNAe1GScQwcG8RzukLsq3O9QH8o= -github.com/temporalio/cli/cliext v0.0.0-20251219173740-f3565da5c390/go.mod h1:A9EHuWgszyY1o70CwnJXLkgqfoGKKHXjkKlpE2fj3Uc= +github.com/temporalio/cli/cliext v0.0.0-20251219224619-5b50c14ff5c9 h1:UEGlUiQNPeK1tvh061KlpVfzD/Sz8lSZRYDpB0ws8CI= +github.com/temporalio/cli/cliext v0.0.0-20251219224619-5b50c14ff5c9/go.mod h1:A9EHuWgszyY1o70CwnJXLkgqfoGKKHXjkKlpE2fj3Uc= 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= @@ -167,12 +167,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= +google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 6ddedb4..b32e4d6 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -40,6 +40,7 @@ type CloudCommand struct { DisablePopUp bool ApiKey string Server string + AutoConfirm bool } func NewCloudCommand(cctx *CommandContext) *CloudCommand { @@ -78,6 +79,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { s.Command.PersistentFlags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods.") s.Command.PersistentFlags().StringVar(&s.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") s.Command.PersistentFlags().StringVar(&s.Server, "server", "saas-api.tmprl-test.cloud:443", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") + s.Command.PersistentFlags().BoolVar(&s.AutoConfirm, "auto-confirm", false, "Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation.") s.initCommand(cctx) return &s } @@ -157,9 +159,11 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud s.Command.Long = "Commands for creating, updating, and managing Temporal Cloud namespaces.\n\nNamespaces provide isolation for workflows and activities. Each namespace\nhas its own configuration including retention period, region, and access\ncontrols." s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewCloudNamespaceApplyCommand(cctx, &s).Command) - s.Command.AddCommand(&NewCloudNamespaceDiffCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceEditCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceUpdateRetentionDaysCommand(cctx, &s).Command) return &s } @@ -171,6 +175,7 @@ type CloudNamespaceApplyCommand struct { AsyncOperationId string Idempotent bool Async bool + VerboseDiff bool } func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceApplyCommand { @@ -192,6 +197,7 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC s.Command.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") s.Command.Flags().BoolVarP(&s.Idempotent, "idempotent", "i", false, "Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed.") s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") + s.Command.Flags().BoolVar(&s.VerboseDiff, "verbose-diff", false, "Show detailed differences between the current and desired namespace configurations when changes are detected.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -200,31 +206,32 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC return &s } -type CloudNamespaceDiffCommand struct { - Parent *CloudNamespaceCommand - Command cobra.Command - Namespace string - Spec string - Verbose bool +type CloudNamespaceDeleteCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + AsyncOperationId string + Async bool + Idempotent bool } -func NewCloudNamespaceDiffCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceDiffCommand { - var s CloudNamespaceDiffCommand +func NewCloudNamespaceDeleteCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceDeleteCommand { + var s CloudNamespaceDeleteCommand s.Parent = parent s.Command.DisableFlagsInUseLine = true - s.Command.Use = "diff [flags]" - s.Command.Short = "Show differences between current and specified namespace configuration" + s.Command.Use = "delete [flags]" + s.Command.Short = "Delete a Temporal Cloud namespace" if hasHighlighting { - s.Command.Long = "Compare the current configuration of a Temporal Cloud namespace with a\nprovided specification. Displays the differences without applying any\nchanges.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample with inline JSON:\n\n\x1b[1mcloud namespace diff --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\x1b[0m\n\nExample with file path:\n\n\x1b[1mcloud namespace diff --spec @namespace-spec.json\x1b[0m" + s.Command.Long = "Delete a Temporal Cloud namespace and all associated data. This action is\nirreversible and will permanently remove all workflows, activities, and\nhistory within the namespace.\n\nExample:\n\n\x1b[1mcloud namespace delete --namespace my-namespace.my-account\x1b[0m" } else { - s.Command.Long = "Compare the current configuration of a Temporal Cloud namespace with a\nprovided specification. Displays the differences without applying any\nchanges.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample with inline JSON:\n\n```\ncloud namespace diff --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\n```\n\nExample with file path:\n\n```\ncloud namespace diff --spec @namespace-spec.json\n```" + s.Command.Long = "Delete a Temporal Cloud namespace and all associated data. This action is\nirreversible and will permanently remove all workflows, activities, and\nhistory within the namespace.\n\nExample:\n\n```\ncloud namespace delete --namespace my-namespace.my-account\n```" } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") - s.Command.Flags().StringVarP(&s.Spec, "spec", "s", "", "Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. Required.") - _ = cobra.MarkFlagRequired(s.Command.Flags(), "spec") - s.Command.Flags().BoolVarP(&s.Verbose, "verbose", "v", false, "Show detailed differences including unchanged fields. By default, only changed fields are shown.") + s.Command.Flags().StringVarP(&s.AsyncOperationId, "async-operation-id", "a", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") + s.Command.Flags().BoolVarP(&s.Async, "async", "c", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") + s.Command.Flags().BoolVarP(&s.Idempotent, "idempotent", "i", false, "Succeed silently if the namespace does not exist. Without this flag, the command errors if the namespace is not found.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -297,6 +304,74 @@ func NewCloudNamespaceGetCommand(cctx *CommandContext, parent *CloudNamespaceCom return &s } +type CloudNamespaceListCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + PageSize int + PageToken string + Name string +} + +func NewCloudNamespaceListCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceListCommand { + var s CloudNamespaceListCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list [flags]" + s.Command.Short = "List Temporal Cloud namespaces" + if hasHighlighting { + s.Command.Long = "List all Temporal Cloud namespaces accessible with the current\nauthentication credentials.\n\nExample:\n\n\x1b[1mcloud namespace list\x1b[0m" + } else { + s.Command.Long = "List all Temporal Cloud namespaces accessible with the current\nauthentication credentials.\n\nExample:\n\n```\ncloud namespace list\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Number of namespaces to return per page. Use for paginated results.") + s.Command.Flags().StringVar(&s.PageToken, "page-token", "", "Token for retrieving the next page of results in a paginated list.") + s.Command.Flags().StringVar(&s.Name, "name", "", "Filter namespaces by the name as defined in the specification of the namespace.") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceUpdateRetentionDaysCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + AsyncOperationId string + Async bool + Idempotent bool + RetentionDays int +} + +func NewCloudNamespaceUpdateRetentionDaysCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceUpdateRetentionDaysCommand { + var s CloudNamespaceUpdateRetentionDaysCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "update-retention-days [flags]" + s.Command.Short = "Update namespace retention period" + if hasHighlighting { + s.Command.Long = "Update the data retention period for a Temporal Cloud namespace. The\nretention period defines how long closed workflow history data are stored.\nExample:\n\n\x1b[1mcloud namespace update-retention-days --namespace my-namespace.my-account --retention-days 14\x1b[0m" + } else { + s.Command.Long = "Update the data retention period for a Temporal Cloud namespace. The\nretention period defines how long closed workflow history data are stored.\nExample:\n\n```\ncloud namespace update-retention-days --namespace my-namespace.my-account --retention-days 14\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") + s.Command.Flags().StringVar(&s.AsyncOperationId, "async-operation-id", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") + s.Command.Flags().BoolVar(&s.Async, "async", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") + s.Command.Flags().BoolVar(&s.Idempotent, "idempotent", false, "Succeed silently if the retention period is already set to the specified value. Without this flag, the command errors when no change is needed.") + s.Command.Flags().IntVarP(&s.RetentionDays, "retention-days", "r", 0, "New retention period in days for closed workflow history data. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "retention-days") + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + var reDays = regexp.MustCompile(`(\d+(\.\d*)?|(\.\d+))d`) type Duration time.Duration diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index b131f23..fb158fe 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -5,6 +5,8 @@ import ( namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "github.com/gogo/protobuf/proto" + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) @@ -46,10 +48,22 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error return err } - asyncOp, err := client.updateNamespace(cctx.Context, newSpec, updateNamespaceParams{ + cctx.Printer.PrintDiff(ns.Spec, newSpec, printer.DiffOptions{}) + // Step 5: Confirm apply if not forced + yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + if !yes { + fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") + return nil + } + + asyncOp, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ + namespace: c.Namespace, + spec: newSpec, asyncOperationID: c.AsyncOperationId, resourceVersion: ns.ResourceVersion, - namespace: c.Namespace, idempotent: c.Idempotent, }) if err != nil { @@ -99,13 +113,36 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error } client := newNamespaceClient(withCloudClient(cloudClient)) - // Step 4: Apply the namespace (create or update) + // Step 4: Retrieve existing namespace + existing, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + cctx.Printer.PrintDiff(existing.Spec, spec, printer.DiffOptions{ + Verbose: c.VerboseDiff, + }) + + // Step 5: Confirm apply if not forced + yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + if !yes { + fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") + return nil + } + + // Step 5: Apply the namespace (create or update) params := applyNamespaceParams{ + namespace: c.Namespace, + spec: spec, + + resourceVersion: existing.ResourceVersion, asyncOperationID: c.AsyncOperationId, // Use the flag value if provided idempotent: c.Idempotent, // Use the flag value } - asyncOp, err := client.applyNamespace(cctx.Context, c.Namespace, spec, params) + asyncOp, err := client.applyNamespace(cctx.Context, params) if err != nil { return fmt.Errorf("failed to apply namespace: %w", err) } @@ -118,7 +155,7 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error Namespace string }{ Status: "unchanged", - Namespace: spec.Name, + Namespace: c.Namespace, } return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) } @@ -133,38 +170,116 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error return pollAsyncOperation(cctx, asyncOp.Id) } -func (c *CloudNamespaceDiffCommand) run(cctx *CommandContext, _ []string) error { - // Step 1: Load spec from file or inline - specData, err := loadJSONSpec(c.Spec) +func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := newCloudClient(cctx) 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) + client := newNamespaceClient(withCloudClient(cloudClient)) + + asyncOp, err := client.deleteNamespace(cctx.Context, deleteNamespaceParams{ + namespace: c.Namespace, + idempotent: c.Idempotent, + asyncOperationID: c.AsyncOperationId, + }) + if err != nil { + return err + } + // 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 *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return 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") + client := newNamespaceClient(withCloudClient(cloudClient)) + + namespaces, nextPageToken, err := client.getNamespaces(cctx.Context, getNamespacesParams{ + pageSize: int32(c.PageSize), + pageToken: c.PageToken, + name: c.Name, + }) + if err != nil { + return err } - // Step 4: Create cloud and namespace clients + return cctx.Printer.PrintStructured( + struct { + Namespaces []*namespace.Namespace + NextPageToken string + }{ + Namespaces: namespaces, + NextPageToken: nextPageToken, + }, + printer.StructuredOptions{}, + ) +} + +func (c *CloudNamespaceUpdateRetentionDaysCommand) run(cctx *CommandContext, _ []string) error { cloudClient, err := newCloudClient(cctx) if err != nil { return err } + client := newNamespaceClient(withCloudClient(cloudClient)) - // Step 5: Retrieve existing namespace - existing, err := client.getNamespace(cctx.Context, c.Namespace) + ns, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + newSpec := proto.Clone(ns.Spec).(*namespace.NamespaceSpec) + newSpec.RetentionDays = int32(c.RetentionDays) + + cctx.Printer.PrintDiff(ns.Spec, newSpec, printer.DiffOptions{}) + // Step 5: Confirm apply if not forced + yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) if err != nil { return err } - // Step 6: Compute and print diff - return cctx.Printer.PrintDiff(existing.Spec, spec, printer.DiffOptions{ - Verbose: c.Verbose, + if !yes { + fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") + return nil + } + + asyncOp, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ + namespace: c.Namespace, + spec: newSpec, + asyncOperationID: c.AsyncOperationId, + resourceVersion: ns.ResourceVersion, + idempotent: c.Idempotent, }) + if err != nil { + return err + } + 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) } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 8df0396..aa27a2d 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -134,6 +134,11 @@ commands: to non-production environments. hidden: true default: saas-api.tmprl-test.cloud:443 + - name: auto-confirm + type: bool + description: | + Automatically confirm prompts and actions that require user + confirmation. Useful for scripting and automation. - name: cloud login summary: Authenticate with Temporal Cloud description: | @@ -265,48 +270,6 @@ commands: description: | Output only the namespace specification in JSON format, omitting metadata and status information. - - name: cloud namespace diff - summary: Show differences between current and specified namespace configuration - description: | - Compare the current configuration of a Temporal Cloud namespace with a - provided specification. Displays the differences without applying any - changes. - - The specification can be provided as inline JSON or loaded from a file - by prefixing the path with '@'. - - Example with inline JSON: - - ``` - cloud namespace diff --spec '{"name": "namespace-name", "region": "us-west-2", "retention_days": 7}' - ``` - - Example with file path: - - ``` - cloud namespace diff --spec @namespace-spec.json - ``` - has-init: false - options: - - name: namespace - type: string - description: | - The fully qualified namespace name in the format 'namespace.account' - (e.g., 'my-namespace.my-account'). - short: n - required: true - - name: spec - type: string - description: | - Namespace configuration in JSON format. Provide inline JSON directly, - or use '@path/to/file.json' to load from a file. - short: s - required: true - - name: verbose - type: bool - short: v - description: | - Show detailed differences including unchanged fields. By default, only changed fields are shown. - name: cloud namespace apply summary: Create or update a namespace from a specification description: | @@ -361,6 +324,11 @@ commands: description: | Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. + - name: verbose-diff + type: bool + description: | + Show detailed differences between the current and desired namespace + configurations when changes are detected. - name: cloud namespace edit summary: Interactively edit a namespace configuration description: | @@ -403,3 +371,108 @@ commands: description: | Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later. + - name: cloud namespace delete + summary: Delete a Temporal Cloud namespace + description: | + Delete a Temporal Cloud namespace and all associated data. This action is + irreversible and will permanently remove all workflows, activities, and + history within the namespace. + + Example: + + ``` + cloud namespace delete --namespace my-namespace.my-account + ``` + has-init: false + options: + - name: namespace + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + required: true + - name: async-operation-id + type: string + short: a + description: | + Custom identifier for tracking this async operation. If not provided, + a unique ID is generated automatically. + - name: async + type: bool + short: c + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: idempotent + type: bool + short: i + description: | + Succeed silently if the namespace does not exist. Without this flag, + the command errors if the namespace is not found. + - name: cloud namespace list + summary: List Temporal Cloud namespaces + description: | + List all Temporal Cloud namespaces accessible with the current + authentication credentials. + + Example: + + ``` + cloud namespace list + ``` + has-init: false + options: + - name: page-size + type: int + description: | + Number of namespaces to return per page. Use for paginated results. + - name: page-token + type: string + description: | + Token for retrieving the next page of results in a paginated list. + - name: name + type: string + description: | + Filter namespaces by the name as defined in the specification of the namespace. + - name: cloud namespace update-retention-days + summary: Update namespace retention period + description: | + Update the data retention period for a Temporal Cloud namespace. The + retention period defines how long closed workflow history data are stored. + Example: + + ``` + cloud namespace update-retention-days --namespace my-namespace.my-account --retention-days 14 + ``` + has-init: false + options: + - name: namespace + type: string + description: | + The fully qualified namespace name in the format 'namespace.account' + (e.g., 'my-namespace.my-account'). + short: n + required: true + - name: async-operation-id + type: string + description: | + Custom identifier for tracking this async operation. If not provided, + a unique ID is generated automatically. + - name: async + type: bool + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: idempotent + type: bool + description: | + Succeed silently if the retention period is already set to the + specified value. Without this flag, the command errors when no change + is needed. + - name: retention-days + type: int + description: | + New retention period in days for closed workflow history data. + short: r + required: true diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index ee2facc..cb8600f 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -34,6 +34,14 @@ func isNothingChangedErr(idempotent bool, e error) bool { return s.Code() == codes.InvalidArgument && strings.Contains(s.Message(), "nothing to change") } +func isNotFoundErr(e error) bool { + s, ok := status.FromError(e) + if !ok { + return false + } + return s.Code() == codes.NotFound +} + // 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) { diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index 9e10054..cdcc4fa 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -47,20 +47,40 @@ func (c *namespaceClient) getNamespace(ctx context.Context, namespace string) (* return res.Namespace, nil } +type getNamespacesParams struct { + pageSize int32 + pageToken string + name string // optional, if set, will filter by name +} + +func (c *namespaceClient) getNamespaces(ctx context.Context, params getNamespacesParams) ([]*namespace.Namespace, string, error) { + res, err := c.client.CloudService().GetNamespaces(ctx, &cloudservice.GetNamespacesRequest{ + PageSize: params.pageSize, + PageToken: params.pageToken, + Name: params.name, + }) + if err != nil { + return nil, "", err + } + + return res.Namespaces, res.NextPageToken, nil +} + type updateNamespaceParams struct { + namespace string + spec *namespace.NamespaceSpec + 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) { +func (c *namespaceClient) updateNamespace(ctx context.Context, 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, + Spec: params.spec, }) if err != nil { if isNothingChangedErr(params.idempotent, err) { @@ -87,26 +107,58 @@ func (c *namespaceClient) createNamespace(ctx context.Context, n *namespace.Name return res.AsyncOperation, nil } -type applyNamespaceParams struct { +type deleteNamespaceParams struct { + namespace string + + resourceVersion string // optional, if empty, will be fetched asyncOperationID string idempotent bool } -func (c *namespaceClient) applyNamespace(ctx context.Context, namespace string, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { - // Try to get the existing namespace - existing, err := c.getNamespace(ctx, namespace) +func (c *namespaceClient) deleteNamespace(ctx context.Context, params deleteNamespaceParams) (*operation.AsyncOperation, error) { + res, err := c.client.CloudService().DeleteNamespace(ctx, &cloudservice.DeleteNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Namespace: params.namespace, + }) if err != nil { + if isNotFoundErr(err) && params.idempotent { + return nil, nil + } return nil, err } + return res.AsyncOperation, nil +} + +type applyNamespaceParams struct { + namespace string + spec *namespace.NamespaceSpec + + resourceVersion string // optional, if empty, will be fetched + asyncOperationID string + idempotent bool +} + +func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamespaceParams) (*operation.AsyncOperation, error) { + if params.resourceVersion == "" { + // Try to get the existing namespace + existing, err := c.getNamespace(ctx, params.namespace) + if err != nil { + return nil, err + } + params.resourceVersion = existing.ResourceVersion + } + // update // Namespace exists, update it using the current resource version updateParams := updateNamespaceParams{ + namespace: params.namespace, + spec: params.spec, + asyncOperationID: params.asyncOperationID, idempotent: params.idempotent, - resourceVersion: existing.ResourceVersion, - namespace: existing.Namespace, + resourceVersion: params.resourceVersion, } - return c.updateNamespace(ctx, n, updateParams) + return c.updateNamespace(ctx, updateParams) } From c25e2838bcdd4b391f8cd8ebd643740c847c9495 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Mon, 5 Jan 2026 15:28:44 -0500 Subject: [PATCH 22/23] update go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 290591e..8b5a24f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.3 require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 + github.com/gogo/protobuf v1.3.2 github.com/kylelemons/godebug v1.1.0 github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -29,7 +30,6 @@ require ( 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.3 // indirect From 67be7670290dd0dab82227c36ca27cefda3e61cc Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Mon, 5 Jan 2026 15:35:50 -0500 Subject: [PATCH 23/23] fix proto clone --- go.mod | 2 +- temporalcloudcli/commands.namespace.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 8b5a24f..290591e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.3 require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 - github.com/gogo/protobuf v1.3.2 github.com/kylelemons/godebug v1.1.0 github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -30,6 +29,7 @@ require ( 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.3 // indirect diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index fb158fe..ed4f1ab 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -5,7 +5,7 @@ import ( namespace "go.temporal.io/cloud-sdk/api/namespace/v1" - "github.com/gogo/protobuf/proto" + "google.golang.org/protobuf/proto" "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" )