diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5ce74ef --- /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 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." + 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 diff --git a/.gitignore b/.gitignore index aaadf73..cf40acf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ *.dll *.so *.dylib +/cloud-cli +/temporal-cloud # Test binary, built with `go test -c` *.test @@ -18,7 +20,7 @@ coverage.* profile.cov # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ # Go workspace file go.work @@ -28,5 +30,9 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ + +# bots and other local information +.local/ +.claude/ 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 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..2c1ac64 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: all gen build + +all: gen build + +gen: + go tool gen-commands -input ./temporalcloudcli/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go + +build: + go build ./cmd/temporal-cloud 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/temporal-cloud/main.go b/cmd/temporal-cloud/main.go new file mode 100644 index 0000000..5757d2c --- /dev/null +++ b/cmd/temporal-cloud/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..290591e --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +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/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 + 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 + 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.78.0 +) + +require ( + 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 + 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 + 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 + 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 + golang.org/x/net v0.48.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-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + gopkg.in/yaml.v3 v3.0.1 // 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.39.0 // indirect + google.golang.org/protobuf v1.36.11 +) + +tool github.com/temporalio/cli/cmd/gen-commands diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..86cce20 --- /dev/null +++ b/go.sum @@ -0,0 +1,182 @@ +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= +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.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= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.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.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= +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/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= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/nexus-rpc/sdk-go v0.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/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= +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.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.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.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-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= +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.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.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.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= +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.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.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= +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= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +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.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= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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-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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go new file mode 100644 index 0000000..c9029e4 --- /dev/null +++ b/temporalcloudcli/cloud.go @@ -0,0 +1,59 @@ +package temporalcloudcli + +import ( + "context" + "errors" + "fmt" + + "github.com/temporalio/cli/cliext" + "go.temporal.io/cloud-sdk/cloudclient" +) + +func (c *CloudCommand) GetAPIKey(ctx context.Context) (string, error) { + loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{}) + if err != nil { + 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 loadClientOauthRes.OAuth == nil || loadClientOauthRes.OAuth.ClientConfig == nil { + return "", fmt.Errorf("no login session found, please run `temporal cloud login`") + } + + token, refreshed, err := GetToken(ctx, loadClientOauthRes.OAuth.ClientConfig, loadClientOauthRes.OAuth.Token) + if err != nil { + 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 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) + } + } + 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 { + // 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 new file mode 100644 index 0000000..b32e4d6 --- /dev/null +++ b/temporalcloudcli/commands.gen.go @@ -0,0 +1,489 @@ +// Code generated. DO NOT EDIT. + +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()) + +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 + ConfigDir string + DisablePopUp bool + ApiKey string + Server string + AutoConfirm bool +} + +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 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 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(&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.") + 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", "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", "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", "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", "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", "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", "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", "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", "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 +} + +type CloudLoginCommand struct { + Parent *CloudCommand + Command cobra.Command + Domain string + Audience string + ClientId string + RedirectUrl string + Reset bool +} + +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 = "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", "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 { + cctx.Options.Fail(err) + } + } + 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 +} + +func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *CloudNamespaceCommand { + var s CloudNamespaceCommand + s.Parent = parent + s.Command.Use = "namespace" + 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(&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 +} + +type CloudNamespaceApplyCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + Spec string + AsyncOperationId string + Idempotent bool + Async bool + VerboseDiff 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 from a specification" + if hasHighlighting { + 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. 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.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().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) + } + } + return &s +} + +type CloudNamespaceDeleteCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + AsyncOperationId string + Async bool + Idempotent bool +} + +func NewCloudNamespaceDeleteCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceDeleteCommand { + var s CloudNamespaceDeleteCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "delete [flags]" + s.Command.Short = "Delete a Temporal Cloud namespace" + if hasHighlighting { + 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 = "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.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) + } + } + return &s +} + +type CloudNamespaceEditCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + AsyncOperationId string + Idempotent bool + Async bool +} + +func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceEditCommand { + var s CloudNamespaceEditCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "edit [flags]" + s.Command.Short = "Interactively edit a namespace configuration" + if hasHighlighting { + 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 = "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 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", "", "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) + } + } + return &s +} + +type CloudNamespaceGetCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + Namespace string + Spec bool +} + +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 = "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 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) + } + } + 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 + +// 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/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..2450a2c --- /dev/null +++ b/temporalcloudcli/commands.login.go @@ -0,0 +1,103 @@ +package temporalcloudcli + +import ( + "fmt" + "net/url" + "reflect" + "strings" + + "github.com/pkg/browser" + "github.com/temporalio/cli/cliext" + "golang.org/x/oauth2" +) + +func (c *CloudLoginCommand) run(cctx *CommandContext, _ []string) error { + var oauthConfig cliext.OAuthConfig + // First load the config to see if we have an existing config + loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{}) + if err != nil { + return fmt.Errorf("failed to load profile: %w", err) + } + 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 + oauthConfig = *loadClientOauthRes.OAuth + } else { + var err error + oauthConfig.ClientConfig, err = c.generateOauthClientConfig() + if err != nil { + return fmt.Errorf("failed to generate OAuth client config: %w", err) + } + } + + oauthToken, err := Login(cctx, oauthConfig.ClientConfig, oauth2.SetAuthURLParam("audience", c.Audience)) + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + + oauthConfig.Token = oauthToken + if err := cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ + OAuth: &oauthConfig, + }); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + fmt.Println("Login successful!") + return 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) generateOauthClientConfig() (*oauth2.Config, error) { + domainURL, err := parseURL(c.Domain) + if err != nil { + return nil, fmt.Errorf("failed to parse domain: %w", err) + } + + return &oauth2.Config{ + ClientID: c.ClientId, + Endpoint: oauth2.Endpoint{ + AuthURL: domainURL.JoinPath("authorize").String(), + TokenURL: domainURL.JoinPath("oauth", "token").String(), + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: c.RedirectUrl, + 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 new file mode 100644 index 0000000..ed4f1ab --- /dev/null +++ b/temporalcloudcli/commands.namespace.go @@ -0,0 +1,285 @@ +package temporalcloudcli + +import ( + "fmt" + + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []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 + } + + if c.Spec { + return cctx.Printer.PrintStructured(n.Spec, printer.StructuredOptions{}) + } + return cctx.Printer.PrintStructured(n, printer.StructuredOptions{}) +} + +func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []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 + } + + 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, + idempotent: c.Idempotent, + }) + if err != nil { + return err + } + + // TODO: (gmankes) remove this -- clean up and make shareable + if asyncOp == nil { + // Nothing changed (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "unchanged", + Namespace: newSpec.Name, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + } + + // Poll for completion + return pollAsyncOperation(cctx, asyncOp.Id) +} + +func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []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) + } + + // Step 3: Create cloud and namespace clients + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + client := newNamespaceClient(withCloudClient(cloudClient)) + + // 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, params) + if err != nil { + return fmt.Errorf("failed to apply namespace: %w", err) + } + + // Step 5: Handle result + if asyncOp == nil { + // Nothing changed (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "unchanged", + Namespace: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Step 6: Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + } + + // Step 7: Poll for completion + return pollAsyncOperation(cctx, asyncOp.Id) +} + +func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return 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 + } + + 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 + } + + 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)) + + 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 + } + 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 new file mode 100644 index 0000000..aa27a2d --- /dev/null +++ b/temporalcloudcli/commands.yml @@ -0,0 +1,478 @@ +# 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 + 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: | + 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: 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: | + 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: | + 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: spec + type: bool + description: | + Output only the namespace specification in JSON format, omitting + metadata and status information. + - 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: 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: | + 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. + - 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 new file mode 100644 index 0000000..cb8600f --- /dev/null +++ b/temporalcloudcli/common.go @@ -0,0 +1,182 @@ +package temporalcloudcli + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func isNothingChangedErr(idempotent bool, e error) bool { + // If we are not idempotent, we should error on nothing to change + if !idempotent { + return false + } + + s, ok := status.FromError(e) + if !ok { + return false + } + return s.Code() == codes.InvalidArgument && strings.Contains(s.Message(), "nothing to change") +} + +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) { + // Check if spec starts with '@' indicating file path + if strings.HasPrefix(spec, "@") { + // Remove '@' prefix and read file + filePath := strings.TrimPrefix(spec, "@") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read spec file %q: %w", filePath, err) + } + return data, nil + } + + // Treat as inline JSON + return []byte(spec), nil +} + +func runEditorForJSONEdit(existing, valuePtr any) error { + existingBytes, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return fmt.Errorf("unable to convert existing object to json: %v", err) + } + updatedBytes, err := runEditor(existingBytes) + if err != nil { + return err + } + return json.Unmarshal(updatedBytes, valuePtr) +} + +func runEditorForJSONEditForProtos(existing, value proto.Message) error { + marshaler := protojson.MarshalOptions{ + EmitUnpopulated: true, + Indent: " ", + } + existingBytes, err := marshaler.Marshal(existing) + if err != nil { + return fmt.Errorf("unable to convert existing object to json: %v", err) + } + updatedBytes, err := runEditor(existingBytes) + if err != nil { + return err + } + unmarshaller := protojson.UnmarshalOptions{} + return unmarshaller.Unmarshal(updatedBytes, value) +} + +func runEditor(existing []byte) ([]byte, error) { + f, err := os.CreateTemp("", "cloud-cli-edit-*.json") + if err != nil { + return nil, fmt.Errorf("unable to create temp file for editing: %v", err) + } + + if _, err := f.Write(existing); err != nil { + return nil, fmt.Errorf("unable to write existing data to temp file for editing: %v", err) + } + + if err := f.Close(); err != nil { + return nil, fmt.Errorf("unable to close temp file: %v", err) + } + + editor := strings.Split(cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), "vim"), " ") + program, args := editor[0], editor[1:] + + cmd := exec.Command(program, append(args, f.Name())...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("error executing %q: %v", editor, err) + } + + updated, err := os.ReadFile(f.Name()) + if err != nil { + return nil, fmt.Errorf("unable to read updated data from temp file: %v", err) + } + + if bytes.Equal(existing, updated) { + return nil, fmt.Errorf("no changes detected") + } + return updated, nil +} + +// pollAsyncOperation polls an async operation until it reaches a terminal state. +// It prints status updates every second and returns the final AsyncOperation. +func pollAsyncOperation( + cctx *CommandContext, + operationID string, +) error { + cloudClient, err := newCloudClient(cctx) + if err != nil { + return err + } + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-cctx.Context.Done(): + return fmt.Errorf("operation polling cancelled: %w", cctx.Context.Err()) + case <-ticker.C: + // Get the current state of the operation + resp, err := cloudClient.CloudService().GetAsyncOperation(cctx.Context, &cloudservice.GetAsyncOperationRequest{ + AsyncOperationId: operationID, + }) + if err != nil { + return fmt.Errorf("failed to get async operation status: %w", err) + } + + asyncOp := resp.GetAsyncOperation() + if asyncOp == nil { + return fmt.Errorf("async operation not found") + } + + // Print current state + switch asyncOp.State { + case operation.AsyncOperation_STATE_PENDING: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_IN_PROGRESS: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation in progress...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_FULFILLED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_FAILED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_CANCELLED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation cancelled\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_REJECTED: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation rejected\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + default: + fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + } + } + } +} diff --git a/temporalcloudcli/internal/printer/printer.go b/temporalcloudcli/internal/printer/printer.go new file mode 100644 index 0000000..f32967d --- /dev/null +++ b/temporalcloudcli/internal/printer/printer.go @@ -0,0 +1,657 @@ +package printer + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "reflect" + "slices" + "strconv" + "strings" + "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" + "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 +} + +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/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/namespace.go b/temporalcloudcli/namespace.go new file mode 100644 index 0000000..cdcc4fa --- /dev/null +++ b/temporalcloudcli/namespace.go @@ -0,0 +1,164 @@ +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 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 +} + +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: params.spec, + }) + if err != nil { + if isNothingChangedErr(params.idempotent, err) { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, nil +} + +func (c *namespaceClient) createNamespace(ctx context.Context, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { + res, err := c.client.CloudService().CreateNamespace(ctx, &cloudservice.CreateNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Spec: n, + }) + if err != nil { + if isNothingChangedErr(params.idempotent, err) { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, nil +} + +type deleteNamespaceParams struct { + namespace string + + resourceVersion string // optional, if empty, will be fetched + asyncOperationID string + idempotent bool +} + +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: params.resourceVersion, + } + + return c.updateNamespace(ctx, updateParams) +} 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 +}