diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7e57b40 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: Check Generated Files +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + verify-generation: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + check-latest: true + cache: false + - name: Add Go bin to PATH + run: echo "$HOME/go/bin" >> $GITHUB_PATH + - 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: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + check-latest: true + cache: false + - name: Build the cli binary + run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0bcd5d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + workflow_dispatch: + release: + types: + - published + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + check-latest: true + cache: true + + - name: Get build date + id: date + run: echo "date=$(date '+%F-%T')" >> "$GITHUB_OUTPUT" + + - name: Get build unix timestamp + id: timestamp + run: echo "timestamp=$(date '+%s')" >> "$GITHUB_OUTPUT" + + - name: Get git branch + id: branch + run: echo "branch=$(git rev-parse --abbrev-ref HEAD)" >> "$GITHUB_OUTPUT" + + - name: Get build platform + id: platform + run: echo "platform=$(go version | cut -d ' ' -f 4)" >> "$GITHUB_OUTPUT" + + - name: Get Go version + id: go + run: echo "go=$(go version | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: v2.12.7 + args: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_DATE: ${{ steps.date.outputs.date }} + BUILD_TS_UNIX: ${{ steps.timestamp.outputs.timestamp }} + GIT_BRANCH: ${{ steps.branch.outputs.branch }} + BUILD_PLATFORM: ${{ steps.platform.outputs.platform }} + GO_VERSION: ${{ steps.go.outputs.go }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1e08e67 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: test + +on: + push: + branches: + - develop + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Unit + Integration (mise) + runs-on: ubuntu-latest + + env: + TEMPORAL_CLOUD_SERVER: ${{ vars.TEMPORAL_CLOUD_SERVER }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_API_KEY }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + check-latest: true + cache: false + + - name: Setup mise + uses: jdx/mise-action@v2 + with: + install: true + + - name: Validate required environment variables and secrets + shell: bash + run: | + set -euo pipefail + + missing=0 + + if [[ -z "${TEMPORAL_CLOUD_SERVER:-}" ]]; then + echo "::error::TEMPORAL_CLOUD_SERVER is not set or is empty" + missing=1 + fi + + + # Secret: check presence ONLY, never print or echo + if [[ -z "${TEMPORAL_API_KEY:-}" ]]; then + echo "::error::TEMPORAL_API_KEY is not set or is empty" + missing=1 + fi + + if [[ "$missing" -ne 0 ]]; then + exit 1 + fi + + - name: Run unit tests + run: | + mise run test + + - name: Run integration tests + run: | + mise run integration diff --git a/.gitignore b/.gitignore index aaadf73..9fafa0d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ *.dll *.so *.dylib +/cloud-cli +/temporal-cloud +dist/ +# the cli clone for installing gen-commands +cli/ # Test binary, built with `go test -c` *.test @@ -18,7 +23,7 @@ coverage.* profile.cov # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ # Go workspace file go.work @@ -28,5 +33,9 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ + +# bots and other local information +.local/ +.claude/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..26f7d3c --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,71 @@ +version: 2 + +before: + hooks: + - go mod download + +release: + prerelease: auto + draft: false + name_template: "v{{.Version}}" + +archives: + - <<: &archive_defaults + name_template: "temporal_cloud_cli_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + id: nix + ids: + - nix + formats: + - tar.gz + files: + - LICENSE + + - <<: *archive_defaults + id: windows-zip + ids: + - windows + formats: + - zip + files: + - LICENSE + + # used by SDKs as zip cannot be used by rust https://github.com/zip-rs/zip/issues/108 + - <<: *archive_defaults + id: windows-targz + ids: + - windows + files: + - LICENSE + +builds: + - <<: &build_defaults + dir: cmd/temporal-cloud + binary: temporal-cloud + ldflags: + - -s -w -X github.com/temporalio/cloud-cli/temporalcloudcli.Version={{.Version}} + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + id: nix + goos: + - linux + - darwin + + - <<: *build_defaults + id: windows + goos: + - windows + hooks: + post: # TODO sign Windows release + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + disable: true + +announce: + skip: "true" diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..fe9b751 --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,135 @@ +# 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` + +## Command Generation Guidelines +1. All commands should have an `--async-operation-id` for mutations. For example, anything that is a create, update, or delete, or any command specific patch commands. +2. All top level spec based commands (for example, namespace) that include a mutation should have an `apply` and `edit` subcommand. The only exception to this will be apikey. +3. All mutation commands should have a `--resource-version` flag that allows the user to specify a resource version for optimistic locking. 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..e229385 --- /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/crew-iam-plus diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..821f746 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: all install gen build test + +# Load .env file if it exists (for local development) +# In CI/CD, environment variables are provided by the environment +-include .env +export + +all: gen build test + +# we need to install gen-commands directly because gen-commands does not have its +# own go.mod +install: + rm -rf ./cli + git clone https://github.com/temporalio/cli && cd ./cli && go install ./cmd/gen-commands && cd .. + rm -rf ./cli + +gen: install + gen-commands -input ./temporalcloudcli/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go + +build: + go build ./cmd/temporal-cloud + +test-integration: + go test -tags=integration ./... + +test: + go test ./... diff --git a/README.md b/README.md index b009473..2c6501b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,13 @@ -# temporal-cloud +# cloud-cli CLI Plugin for Temporal Cloud + +## Testing +In order to run the tests, you need a temporal cloud api key. Create one in the [cloud dashboard](https://cloud.temporal.io/) and place it in a .env file at the root directory of the repo as follows: +``` +TEMPORAL_API_KEY= +TEMPORAL_CLOUD_SERVER= +``` + +*NOTE* This will create and delete resources on the account. + +Then run with `mise run test` to run the tests. \ No newline at end of file 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..b93f167 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module github.com/temporalio/cloud-cli + +go 1.25.5 + +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-20260112210410-f2d230be226c + 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 + 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/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/nexus-rpc/sdk-go v0.5.1 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/stretchr/objx v0.5.3 // 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd6138e --- /dev/null +++ b/go.sum @@ -0,0 +1,164 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +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/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/cliext v0.0.0-20260112210410-f2d230be226c h1:E+NxnXPj6XeDN8uUTI3veXYt7PVqfZOrOMh7kRkAEI4= +github.com/temporalio/cli/cliext v0.0.0-20260112210410-f2d230be226c/go.mod h1:A9EHuWgszyY1o70CwnJXLkgqfoGKKHXjkKlpE2fj3Uc= +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.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/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/mise.toml b/mise.toml new file mode 100644 index 0000000..dd5b933 --- /dev/null +++ b/mise.toml @@ -0,0 +1,18 @@ +[tools] +# Note: Keep in sync with `toolchain` directive in `go.mod`. +go = "1.25.5" + +# needed for go backends +[settings] +experimental = true + +# Add .local/bin to PATH for sc binaries +[env] +_.path = [".local/bin"] +GOPRIVATE = "go.temporal.io/temporal,go.temporal.io/temporal-proto,github.com/temporalio" + +[tasks] +build = "make build" +gen = "make gen" +test = "make test" +integration = "make test-integration" diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go new file mode 100644 index 0000000..bcfb8ce --- /dev/null +++ b/temporalcloudcli/cloud.go @@ -0,0 +1,111 @@ +package temporalcloudcli + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/temporalio/cli/cliext" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/log" +) + +type CloudOptions struct { + ApiKey string + Server string + cliext.CommonOptions + Logger log.Logger +} + +type CloudOptionsBuilder struct { + // CommonOptions contains common CLI options including profile config. + CommonOptions cliext.CommonOptions + // ClientOptions contains the client configuration from flags. + ClientOptions ClientOptions + // EnvLookup is the environment variable lookup function. + // If nil, environment variables are not used for profile loading. + EnvLookup envconfig.EnvLookup + // Logger is the slog logger to use for the client. If set, it will be + // wrapped with the SDK's structured logger adapter. + Logger *slog.Logger +} + +func (b *CloudOptionsBuilder) Build(ctx context.Context) (*CloudOptions, error) { + cfg := b.ClientOptions + common := b.CommonOptions + + // Load a client config profile if configured + var profile envconfig.ClientConfigProfile + if !common.DisableConfigFile || !common.DisableConfigEnv { + var err error + profile, err = envconfig.LoadClientConfigProfile(envconfig.LoadClientConfigProfileOptions{ + ConfigFilePath: common.ConfigFile, + ConfigFileProfile: common.Profile, + DisableFile: common.DisableConfigFile, + DisableEnv: common.DisableConfigEnv, + EnvLookup: b.EnvLookup, + }) + if err != nil { + return nil, fmt.Errorf("failed loading client config: %w", err) + } + } + + cloudOpts := &CloudOptions{ + CommonOptions: common, + } + + // Set logger if provided. + if b.Logger != nil { + cloudOpts.Logger = log.NewStructuredLogger(b.Logger) + } + + // Set API key on profile if provided + if cfg.ApiKey != "" { + cloudOpts.ApiKey = cfg.ApiKey + } else if profile.APIKey != "" { + cloudOpts.ApiKey = profile.APIKey + } + + if cfg.Server != "" { + cloudOpts.Server = cfg.Server + } + + return cloudOpts, nil +} + +func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) { + loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{ + ConfigFilePath: c.ConfigFile, + ProfileName: c.Profile, + EnvLookup: envconfig.EnvLookupOS, + }) + 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`: %w", 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, + ConfigFilePath: c.ConfigFile, + ProfileName: c.Profile, + EnvLookup: envconfig.EnvLookupOS, + }); err != nil { + return "", fmt.Errorf("failed to write config file: %w", err) + } + } + return token.AccessToken, nil +} diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go new file mode 100644 index 0000000..56fef4f --- /dev/null +++ b/temporalcloudcli/commands.gen.go @@ -0,0 +1,523 @@ +// Code generated. DO NOT EDIT. + +package temporalcloudcli + +import ( + "github.com/mattn/go-isatty" + + "github.com/spf13/cobra" + + "github.com/spf13/pflag" + + "github.com/temporalio/cli/cliext" + + "os" +) + +var hasHighlighting = isatty.IsTerminal(os.Stdout.Fd()) + +type ClientOptions struct { + ApiKey string + Server string + FlagSet *pflag.FlagSet +} + +func (v *ClientOptions) BuildFlags(f *pflag.FlagSet) { + v.FlagSet = f + f.StringVar(&v.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") + f.StringVar(&v.Server, "server", "saas-api.tmprl-test.cloud:443", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") + _ = f.MarkHidden("server") +} + +type DiffOptions struct { + VerboseDiff bool + FlagSet *pflag.FlagSet +} + +func (v *DiffOptions) BuildFlags(f *pflag.FlagSet) { + v.FlagSet = f + f.BoolVar(&v.VerboseDiff, "verbose-diff", false, "Show detailed differences between the current and desired namespace configurations when changes are detected.") +} + +type CloudCommand struct { + Command cobra.Command + ClientOptions + cliext.CommonOptions + ConfigDir string + DisablePopUp bool + 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.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().BoolVar(&s.AutoConfirm, "auto-confirm", false, "Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation.") + s.ClientOptions.BuildFlags(s.Command.PersistentFlags()) + s.CommonOptions.BuildFlags(s.Command.PersistentFlags()) + 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().MarkHidden("domain") + s.Command.Flags().StringVar(&s.Audience, "audience", "https://saas-api.tmprl-test.cloud", "OAuth audience parameter for token generation.") + _ = s.Command.Flags().MarkHidden("audience") + s.Command.Flags().StringVar(&s.ClientId, "client-id", "XBimMwn90eAnjsiGVbAJ3Hgd9z06jjJB", "OAuth client identifier for authentication.") + _ = s.Command.Flags().MarkHidden("client-id") + s.Command.Flags().StringVar(&s.RedirectUrl, "redirect-url", "http://127.0.0.1:56628/callback", "Redirect URL for OAuth authentication flow.") + _ = s.Command.Flags().MarkHidden("redirect-url") + 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.Flags().MarkHidden("domain") + 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(&NewCloudNamespaceLifecycleCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceRetentionCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceApplyCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + ClientOptions + DiffOptions + Spec string + AsyncOperationId string + Idempotent bool + Async bool + ResourceVersion string +} + +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().StringVar(&s.Spec, "spec", "", "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().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.Idempotent, "idempotent", false, "Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed.") + 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().StringVarP(&s.ResourceVersion, "resource-version", "v", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + 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 + ClientOptions + Namespace string + AsyncOperationId string + Async bool + Idempotent bool + ResourceVersion string +} + +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().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 namespace does not exist. Without this flag, the command errors if the namespace is not found.") + s.Command.Flags().StringVarP(&s.ResourceVersion, "resource-version", "v", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + 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 + ClientOptions + DiffOptions + Namespace string + AsyncOperationId string + Idempotent bool + Async bool + ResourceVersion string +} + +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().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.Idempotent, "idempotent", 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().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().StringVarP(&s.ResourceVersion, "resource-version", "v", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + 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 + ClientOptions + 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.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceLifecycleCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command +} + +func NewCloudNamespaceLifecycleCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceLifecycleCommand { + var s CloudNamespaceLifecycleCommand + s.Parent = parent + s.Command.Use = "lifecycle" + s.Command.Short = "Manage namespace lifecycle settings" + s.Command.Long = "Commands for managing lifecycle settings of Temporal Cloud namespaces.\n\nLifecycle settings control the behavior and protection of namespaces,\nincluding delete protection to prevent accidental deletion." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceLifecycleGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceLifecycleSetCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceLifecycleGetCommand struct { + Parent *CloudNamespaceLifecycleCommand + Command cobra.Command + ClientOptions + Namespace string +} + +func NewCloudNamespaceLifecycleGetCommand(cctx *CommandContext, parent *CloudNamespaceLifecycleCommand) *CloudNamespaceLifecycleGetCommand { + var s CloudNamespaceLifecycleGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Get namespace lifecycle configuration" + if hasHighlighting { + s.Command.Long = "Retrieve the current lifecycle configuration for a Temporal Cloud namespace.\nLifecycle settings include delete protection status.\n\nExample:\n\n\x1b[1mcloud namespace lifecycle get --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Retrieve the current lifecycle configuration for a Temporal Cloud namespace.\nLifecycle settings include delete protection status.\n\nExample:\n\n```\ncloud namespace lifecycle 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.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceLifecycleSetCommand struct { + Parent *CloudNamespaceLifecycleCommand + Command cobra.Command + ClientOptions + DiffOptions + Namespace string + EnableDeleteProtection bool + AsyncOperationId string + Async bool + Idempotent bool + ResourceVersion string +} + +func NewCloudNamespaceLifecycleSetCommand(cctx *CommandContext, parent *CloudNamespaceLifecycleCommand) *CloudNamespaceLifecycleSetCommand { + var s CloudNamespaceLifecycleSetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set [flags]" + s.Command.Short = "Set namespace lifecycle configuration" + if hasHighlighting { + s.Command.Long = "Set the lifecycle configuration for a Temporal Cloud namespace.\nLifecycle settings include delete protection to prevent accidental deletion.\n\nExample:\n\n\x1b[1mcloud namespace lifecycle set --namespace my-namespace.my-account --enable-delete-protection true\x1b[0m" + } else { + s.Command.Long = "Set the lifecycle configuration for a Temporal Cloud namespace.\nLifecycle settings include delete protection to prevent accidental deletion.\n\nExample:\n\n```\ncloud namespace lifecycle set --namespace my-namespace.my-account --enable-delete-protection true\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.EnableDeleteProtection, "enable-delete-protection", false, "Enable or disable delete protection for the namespace. When enabled, the namespace cannot be deleted until this flag is set to false. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "enable-delete-protection") + 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 lifecycle configuration is already set to the specified value. Without this flag, the command errors when no change is needed.") + s.Command.Flags().StringVar(&s.ResourceVersion, "resource-version", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + 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 + ClientOptions + 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.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceRetentionCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command +} + +func NewCloudNamespaceRetentionCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceRetentionCommand { + var s CloudNamespaceRetentionCommand + s.Parent = parent + s.Command.Use = "retention" + s.Command.Short = "Manage namespace retention settings" + s.Command.Long = "Commands for managing data retention settings of Temporal Cloud namespaces.\n\nRetention determines how long closed workflow history data are stored before\nbeing automatically deleted." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceRetentionGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceRetentionSetCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceRetentionGetCommand struct { + Parent *CloudNamespaceRetentionCommand + Command cobra.Command + ClientOptions + Namespace string +} + +func NewCloudNamespaceRetentionGetCommand(cctx *CommandContext, parent *CloudNamespaceRetentionCommand) *CloudNamespaceRetentionGetCommand { + var s CloudNamespaceRetentionGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Get namespace retention period" + if hasHighlighting { + s.Command.Long = "Retrieve the current data retention period for a Temporal Cloud namespace.\nThe retention period defines how long closed workflow history data are stored.\n\nExample:\n\n\x1b[1mcloud namespace retention get --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Retrieve the current data retention period for a Temporal Cloud namespace.\nThe retention period defines how long closed workflow history data are stored.\n\nExample:\n\n```\ncloud namespace retention 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.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceRetentionSetCommand struct { + Parent *CloudNamespaceRetentionCommand + Command cobra.Command + ClientOptions + DiffOptions + Namespace string + AsyncOperationId string + Async bool + Idempotent bool + RetentionDays int + ResourceVersion string +} + +func NewCloudNamespaceRetentionSetCommand(cctx *CommandContext, parent *CloudNamespaceRetentionCommand) *CloudNamespaceRetentionSetCommand { + var s CloudNamespaceRetentionSetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set [flags]" + s.Command.Short = "Set namespace retention period" + if hasHighlighting { + s.Command.Long = "Set the data retention period for a Temporal Cloud namespace. The\nretention period defines how long closed workflow history data are stored.\n\nExample:\n\n\x1b[1mcloud namespace retention set --namespace my-namespace.my-account --retention-days 14\x1b[0m" + } else { + s.Command.Long = "Set the data retention period for a Temporal Cloud namespace. The\nretention period defines how long closed workflow history data are stored.\n\nExample:\n\n```\ncloud namespace retention set --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().IntVar(&s.RetentionDays, "retention-days", 0, "New retention period in days for closed workflow history data. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "retention-days") + s.Command.Flags().StringVar(&s.ResourceVersion, "resource-version", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go new file mode 100644 index 0000000..806925b --- /dev/null +++ b/temporalcloudcli/commands.go @@ -0,0 +1,562 @@ +package temporalcloudcli + +import ( + "bufio" + "context" + "encoding/json" + "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" + "go.temporal.io/api/common/v1" + "go.temporal.io/api/failure/v1" + "go.temporal.io/api/temporalproto" + "go.temporal.io/cloud-sdk/cloudclient" + "go.temporal.io/sdk/contrib/envconfig" + "golang.org/x/term" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// 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 +} + +// BuildCloudOptions creates CloudOptions from the command's ClientOptions. +// It uses the RootCommand's CommonOptions and the CommandContext's logger. +// +// This method encapsulates the CloudOptionsBuilder pattern and should be used +// by all commands that need CloudOptions. +// +// AIDEV-NOTE: This is the standard way for commands to create CloudOptions. +// It automatically uses cctx.RootCommand.CommonOptions regardless of command depth. +func (cctx *CommandContext) BuildCloudOptions(clientOpts ClientOptions) (*CloudOptions, error) { + builder := CloudOptionsBuilder{ + ClientOptions: clientOpts, + CommonOptions: cctx.RootCommand.CommonOptions, + Logger: cctx.Logger, + EnvLookup: envconfig.EnvLookupOS, + } + return builder.Build(cctx.Context) +} + +// BuildCloudClient creates a CloudClient from the command's ClientOptions. +// It builds CloudOptions internally and then creates the client. +// +// This is a convenience method for commands that need the CloudClient directly +// without needing to keep a reference to CloudOptions. +// +// AIDEV-NOTE: Use this method in command run functions instead of manually +// creating CloudOptions and CloudClient separately. +func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudclient.Client, error) { + cloudOpts, err := cctx.BuildCloudOptions(clientOpts) + if err != nil { + return nil, err + } + opts := cloudclient.Options{ + UserAgent: fmt.Sprintf("temporalio-cloud-cli/%s", VersionString()), + } + if cloudOpts.Server != "" { + opts.HostPort = cloudOpts.Server + } + if cloudOpts.ApiKey != "" { + // an explicit api key was provided, use it + opts.APIKey = cloudOpts.ApiKey + } else { + // fallaback to the oauth based sso token provider + opts.APIKeyReader = cloudOpts + } + + cloudClient, err := cloudclient.New(opts) + if err != nil { + return nil, err + } + return cloudClient, 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 + // AIDEV-NOTE: Store cancel function for command timeout context to prevent resource leak + var timeoutCancel context.CancelFunc + 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, &timeoutCancel) + + 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) { + // AIDEV-NOTE: Clean up command timeout context to prevent resource leak + if timeoutCancel != nil { + timeoutCancel() + } + 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/cloud-cli/temporalcloudcli.buildInfo=" + var bi = buildInfo + if bi != "" { + bi = fmt.Sprintf(", %s", bi) + } + return fmt.Sprintf("%s%s", Version, bi) +} + +func (c *CloudCommand) preRun(cctx *CommandContext, timeoutCancel *context.CancelFunc) 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 { + // AIDEV-NOTE: Store cancel function to prevent timeout goroutine leak + cctx.Context, *timeoutCancel = 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 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 } + +type nopWriter struct{} + +func (nopWriter) Write(b []byte) (int, error) { return len(b), nil } diff --git a/temporalcloudcli/commands.login.go b/temporalcloudcli/commands.login.go new file mode 100644 index 0000000..690e7a9 --- /dev/null +++ b/temporalcloudcli/commands.login.go @@ -0,0 +1,105 @@ +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 && + loadClientOauthRes.OAuth.Token != nil && + loadClientOauthRes.OAuth.ClientConfig != 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) + } + cctx.Printer.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") + cctx.Printer.Println(fmt.Sprintf("Opening browser to logout. If it doesn't open, visit: %s", 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..419a5c2 --- /dev/null +++ b/temporalcloudcli/commands.namespace.go @@ -0,0 +1,284 @@ +package temporalcloudcli + +import ( + "fmt" + + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudNamespaceGetCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + 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 := cctx.BuildCloudClient(c.ClientOptions) + 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 + } + + err = promptApplyResource(cctx, ns.Spec, newSpec, c.VerboseDiff) + if err != nil { + return err + } + + // Use provided resource version, or fall back to the fetched namespace's resource version. + resourceVersion := c.ResourceVersion + if resourceVersion == "" { + resourceVersion = ns.ResourceVersion + } + + asyncOp, err := client.updateNamespace(cctx.Context, updateNamespaceParams{ + namespace: c.Namespace, + spec: newSpec, + asyncOperationID: c.AsyncOperationId, + resourceVersion: 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: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: c.Namespace, + }, printer.StructuredOptions{}) + } + + // Poll for completion + return PollAsyncOperation(cctx, cloudClient, asyncOp.Id, c.Namespace) +} + +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 := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + client := newNamespaceClient(withCloudClient(cloudClient)) + + // Step 4: Retrieve existing namespace + var found bool + existing, err := client.getNamespaceByName(cctx.Context, spec.Name) + if err != nil && !isNotFoundErr(err) { + return err + } else if err == nil { + found = true + } + + existingResourceVersion := "" + var existingSpec *namespace.NamespaceSpec + existingNamespaceIdentifier := "" + + if found { + existingResourceVersion = existing.ResourceVersion + existingSpec = existing.Spec + existingNamespaceIdentifier = existing.Namespace + } + + // Step 5: Confirm apply if not forced + err = promptApplyResource(cctx, existingSpec, spec, c.VerboseDiff) + if err != nil { + return err + } + + // Step 6: Apply the namespace (create or update) + // Use provided resource version, or use fetched version + resourceVersion := c.ResourceVersion + if resourceVersion == "" { + resourceVersion = existingResourceVersion + } + + var asyncOp *operation.AsyncOperation + var namespaceID string + + if found { + // Update existing namespace + asyncOp, err = client.updateNamespace(cctx.Context, updateNamespaceParams{ + namespace: existingNamespaceIdentifier, + spec: spec, + asyncOperationID: c.AsyncOperationId, + idempotent: c.Idempotent, + resourceVersion: resourceVersion, + }) + if err != nil { + return fmt.Errorf("failed to update namespace: %w", err) + } + namespaceID = existingNamespaceIdentifier + } else { + // Create new namespace + res, err := client.createNamespace(cctx.Context, createNamespaceParams{ + spec: spec, + asyncOperationID: c.AsyncOperationId, + }) + if err != nil { + return fmt.Errorf("failed to create namespace: %w", err) + } + asyncOp = res.asyncOp + namespaceID = res.Namespace + } + + // Step 7: Handle result + if asyncOp == nil { + // Nothing changed (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "unchanged", + Namespace: namespaceID, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Step 8: Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: namespaceID, + }, printer.StructuredOptions{}) + } + + // Step 7: Poll for completion + return PollAsyncOperation(cctx, cloudClient, asyncOp.Id, namespaceID) +} + +func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + yes, err := cctx.promptYes("Delete (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + + if !yes { + return fmt.Errorf("Aborting delete.") + } + + asyncOp, err := client.deleteNamespace(cctx.Context, deleteNamespaceParams{ + namespace: c.Namespace, + idempotent: c.Idempotent, + asyncOperationID: c.AsyncOperationId, + resourceVersion: c.ResourceVersion, + }) + if err != nil { + return err + } + + if asyncOp == nil { + // deleted already (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "deleted", + Namespace: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: c.Namespace, + }, printer.StructuredOptions{}) + } + + // Poll for completion + return PollAsyncOperation(cctx, cloudClient, asyncOp.Id, c.Namespace) +} + +func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + 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{}, + ) +} diff --git a/temporalcloudcli/commands.namespace.lifecycle.go b/temporalcloudcli/commands.namespace.lifecycle.go new file mode 100644 index 0000000..9e22a48 --- /dev/null +++ b/temporalcloudcli/commands.namespace.lifecycle.go @@ -0,0 +1,109 @@ +package temporalcloudcli + +import ( + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudNamespaceLifecycleGetCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + ns, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + // Extract lifecycle configuration, handle nil case + enableDeleteProtection := false + if ns.Spec.Lifecycle != nil { + enableDeleteProtection = ns.Spec.Lifecycle.EnableDeleteProtection + } + + // Create focused output showing only lifecycle information + result := struct { + Namespace string `json:"namespace"` + EnableDeleteProtection bool `json:"enableDeleteProtection"` + }{ + Namespace: ns.Namespace, + EnableDeleteProtection: enableDeleteProtection, + } + + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) +} + +func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + ns, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + // Clone the spec and update lifecycle + newSpec := proto.Clone(ns.Spec).(*namespace.NamespaceSpec) + + // Ensure lifecycle is initialized + if newSpec.Lifecycle == nil { + newSpec.Lifecycle = &namespace.LifecycleSpec{} + } + newSpec.Lifecycle.EnableDeleteProtection = c.EnableDeleteProtection + + // Show diff + err = promptApplyResource(cctx, ns.Spec, newSpec, c.VerboseDiff) + if err != nil { + return err + } + + // Use provided resource version, or fetch from current namespace + resourceVersion := c.ResourceVersion + if resourceVersion == "" { + resourceVersion = ns.ResourceVersion + } + + asyncOp, err := client.updateNamespace(cctx.Context, updateNamespaceParams{ + namespace: c.Namespace, + spec: newSpec, + asyncOperationID: c.AsyncOperationId, + resourceVersion: 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: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: c.Namespace, + }, printer.StructuredOptions{}) + } + + // Poll for completion + return PollAsyncOperation(cctx, cloudClient, asyncOp.Id, c.Namespace) +} diff --git a/temporalcloudcli/commands.namespace.retention.go b/temporalcloudcli/commands.namespace.retention.go new file mode 100644 index 0000000..c2b3fce --- /dev/null +++ b/temporalcloudcli/commands.namespace.retention.go @@ -0,0 +1,95 @@ +package temporalcloudcli + +import ( + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudNamespaceRetentionSetCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + 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) + + err = promptApplyResource(cctx, ns.Spec, newSpec, c.VerboseDiff) + if err != nil { + return err + } + + // Use provided resource version, or fetch from current namespace + resourceVersion := c.ResourceVersion + if resourceVersion == "" { + resourceVersion = ns.ResourceVersion + } + + asyncOp, err := client.updateNamespace(cctx.Context, updateNamespaceParams{ + namespace: c.Namespace, + spec: newSpec, + asyncOperationID: c.AsyncOperationId, + resourceVersion: 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: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + + // Handle async flag + if c.Async { + // Return immediately with the async operation + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: c.Namespace, + }, printer.StructuredOptions{}) + } + + // Poll for completion + return PollAsyncOperation(cctx, cloudClient, asyncOp.Id, c.Namespace) +} + +func (c *CloudNamespaceRetentionGetCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + + client := newNamespaceClient(withCloudClient(cloudClient)) + + ns, err := client.getNamespace(cctx.Context, c.Namespace) + if err != nil { + return err + } + + // Create focused output showing only retention information + result := struct { + Namespace string `json:"namespace"` + RetentionDays int32 `json:"retentionDays"` + }{ + Namespace: ns.Namespace, + RetentionDays: ns.Spec.RetentionDays, + } + + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) +} diff --git a/temporalcloudcli/commands.namespace_test.go b/temporalcloudcli/commands.namespace_test.go new file mode 100644 index 0000000..6bb9913 --- /dev/null +++ b/temporalcloudcli/commands.namespace_test.go @@ -0,0 +1,234 @@ +//go:build integration +// +build integration + +package temporalcloudcli_test + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/temporalio/cloud-cli/temporalcloudcli" + "go.temporal.io/api/temporalproto" + "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + resource "go.temporal.io/cloud-sdk/api/resource/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +const ( + e2eNamespacePrefix = "e2e-namespace" +) + +func (s *SharedServerSuite) generateRandomNamespaceName() string { + return fmt.Sprintf("%s-%s", e2eNamespacePrefix, s.generateRandomID()) +} + +func (s *SharedServerSuite) TestBasicNamespaceOperations() { + s.cleanupNamespaces() + s.testnamespaceCRUD() + s.cleanupNamespaces() +} + +func (s *SharedServerSuite) testnamespaceCRUD() { + cloudClient := s.getCloudClient() + // create a new namespace + newNamespaceName := s.generateRandomNamespaceName() + + namespaceSpec := &namespace.NamespaceSpec{ + Name: newNamespaceName, + Regions: []string{"aws-us-east-1"}, + RetentionDays: 30, + ApiKeyAuth: &namespace.ApiKeyAuthSpec{ + Enabled: true, + }, + SearchAttributes: map[string]namespace.NamespaceSpec_SearchAttributeType{ + "test": namespace.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, + }, + Lifecycle: &namespace.LifecycleSpec{}, + } + + buf, err := temporalproto.CustomJSONMarshalOptions{}.Marshal(namespaceSpec) + s.Suite.Require().NoError(err) + + res := s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "apply", + "--auto-confirm=true", + "--spec", fmt.Sprintf(`%s`, string(buf)), + "-o=json", + ) + + s.Suite.Require().NoError(res.Err) + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + result := &temporalcloudcli.MutationResult{} + err = json.Unmarshal(buf, result) + s.Suite.Require().NoError(err) + + namespaceID := result.ID + + // make sure ns apply is completed + asyncOpID := result.AsyncOp.Id + err = s.pollAsyncOperation(cloudClient, asyncOpID) + s.Suite.Require().NoError(err) + + // get the namespace + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "get", + "-n", namespaceID, + "-o=json", + ) + s.Suite.Require().NoError(res.Err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + readNamespace := &namespace.Namespace{} + err = protojson.Unmarshal(buf, readNamespace) + s.Suite.Require().NoError(err) + + // compare it to the inputted spec + s.Suite.Require().NotNil(readNamespace) + s.Suite.Require().NotNil(readNamespace.Spec) + s.Suite.Equal(namespaceSpec.Name, readNamespace.Spec.Name) + s.Suite.Equal(namespaceSpec.Regions, readNamespace.Spec.Regions) + s.Suite.Equal(namespaceSpec.SearchAttributes, readNamespace.Spec.SearchAttributes) + s.Suite.Equal(namespaceSpec.RetentionDays, readNamespace.Spec.RetentionDays) + + // get the namespace via listing + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "list", + fmt.Sprintf("--name=%s", newNamespaceName), + "-o=json", + ) + s.Suite.Require().NoError(res.Err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + // simply assert that the list contains the namespace + s.Suite.Contains(string(buf), newNamespaceName) + + // update the namespace + namespaceSpec.RetentionDays-- + + buf, err = temporalproto.CustomJSONMarshalOptions{}.Marshal(namespaceSpec) + s.Suite.Require().NoError(err) + + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "apply", + "--auto-confirm=true", + "--spec", fmt.Sprintf(`%s`, string(buf)), + "-o=json", + ) + s.Suite.Require().NoError(res.Err) + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + result = &temporalcloudcli.MutationResult{} + err = json.Unmarshal(buf, result) + s.Suite.Require().NoError(err) + + // make sure ns apply is completed + asyncOpID = result.AsyncOp.Id + err = s.pollAsyncOperation(cloudClient, asyncOpID) + s.Suite.Require().NoError(err) + + // get the namespace (after updating) + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "get", + "-n", namespaceID, + "-o=json", + ) + s.Suite.Require().NoError(res.Err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + readNamespace = &namespace.Namespace{} + err = protojson.Unmarshal(buf, readNamespace) + s.Suite.Require().NoError(err) + + // compare it to the inputted spec + s.Suite.Require().NotNil(readNamespace) + s.Suite.Require().NotNil(readNamespace.Spec) + s.Suite.Equal(namespaceSpec.Name, readNamespace.Spec.Name) + s.Suite.Equal(namespaceSpec.Regions, readNamespace.Spec.Regions) + s.Suite.Equal(namespaceSpec.SearchAttributes, readNamespace.Spec.SearchAttributes) + s.Suite.Equal(namespaceSpec.RetentionDays, readNamespace.Spec.RetentionDays) + + // delete the namespace + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "delete", + "-n", namespaceID, + "--idempotent", + "--auto-confirm=true", + "-o=json", + ) + s.Suite.Require().NoError(res.Err) + + // try to get the namespace + res = s.Execute( + "namespace", + fmt.Sprintf("--server=%s", s.server), // TODO (gmankes): remove this when the server is defaulted back to prod + "get", + "-n", namespaceID, + ) + + s.Suite.Require().Error(res.Err) + // should say not found + stdOut, err := io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + s.Suite.NotContains(strings.ToLower(string(stdOut)), newNamespaceName) +} + +func (s *SharedServerSuite) cleanupNamespaces() { + cloudClient := s.getCloudClient() + + pageToken := "" + namespacesToClean := []*namespace.Namespace{} + for { + res, err := cloudClient.CloudService().GetNamespaces(s.Context, &cloudservice.GetNamespacesRequest{ + PageToken: pageToken, + }) + s.Suite.Require().NoError(err) + for _, ns := range res.Namespaces { + if strings.HasPrefix(ns.Namespace, e2eNamespacePrefix) { + namespacesToClean = append(namespacesToClean, ns) + } + } + if res.NextPageToken == "" { + break + } + pageToken = res.NextPageToken + } + for _, ns := range namespacesToClean { + // AIDEV-NOTE: Only delete namespaces that are in ACTIVE state (value 3). + // Namespaces in other states (DELETING, DELETED, etc.) cannot be deleted. + if ns.State != resource.ResourceState_RESOURCE_STATE_ACTIVE { + continue + } + res, err := cloudClient.CloudService().DeleteNamespace(s.Context, &cloudservice.DeleteNamespaceRequest{ + ResourceVersion: ns.ResourceVersion, + Namespace: ns.Namespace, + }) + s.Suite.NoError(err) + if err == nil { + pollErr := s.pollAsyncOperation(cloudClient, res.AsyncOperation.Id) + s.Suite.NoError(pollErr) + } + } +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml new file mode 100644 index 0000000..9329a53 --- /dev/null +++ b/temporalcloudcli/commands.yml @@ -0,0 +1,522 @@ +# 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 + option-sets: + - client + - common + options: + - 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: 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 + option-sets: + - client + 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 + option-sets: + - client + - diff + options: + - 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. + 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: idempotent + type: bool + 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 + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: resource-version + type: string + short: v + description: | + Resource version for optimistic concurrency control. If not provided, + the current version is fetched automatically. + - 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 + option-sets: + - client + - diff + 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: idempotent + type: bool + 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 + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: resource-version + type: string + short: v + description: | + Resource version for optimistic concurrency control. If not provided, + the current version is fetched automatically. + - 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 + option-sets: + - client + 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 namespace does not exist. Without this flag, + the command errors if the namespace is not found. + - name: resource-version + type: string + short: v + description: | + Resource version for optimistic concurrency control. If not provided, + the current version is fetched automatically. + - 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 + option-sets: + - client + 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 retention + summary: Manage namespace retention settings + description: | + Commands for managing data retention settings of Temporal Cloud namespaces. + + Retention determines how long closed workflow history data are stored before + being automatically deleted. + has-init: false + - name: cloud namespace retention get + summary: Get namespace retention period + description: | + Retrieve the current data retention period for a Temporal Cloud namespace. + The retention period defines how long closed workflow history data are stored. + + Example: + + ``` + cloud namespace retention get --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + 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: cloud namespace retention set + summary: Set namespace retention period + description: | + Set the data retention period for a Temporal Cloud namespace. The + retention period defines how long closed workflow history data are stored. + + Example: + + ``` + cloud namespace retention set --namespace my-namespace.my-account --retention-days 14 + ``` + has-init: false + option-sets: + - client + - diff + 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. + required: true + - name: resource-version + type: string + description: | + Resource version for optimistic concurrency control. If not provided, + the current version is fetched automatically. + - name: cloud namespace lifecycle + summary: Manage namespace lifecycle settings + description: | + Commands for managing lifecycle settings of Temporal Cloud namespaces. + + Lifecycle settings control the behavior and protection of namespaces, + including delete protection to prevent accidental deletion. + has-init: false + - name: cloud namespace lifecycle get + summary: Get namespace lifecycle configuration + description: | + Retrieve the current lifecycle configuration for a Temporal Cloud namespace. + Lifecycle settings include delete protection status. + + Example: + + ``` + cloud namespace lifecycle get --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + 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: cloud namespace lifecycle set + summary: Set namespace lifecycle configuration + description: | + Set the lifecycle configuration for a Temporal Cloud namespace. + Lifecycle settings include delete protection to prevent accidental deletion. + + Example: + + ``` + cloud namespace lifecycle set --namespace my-namespace.my-account --enable-delete-protection true + ``` + has-init: false + option-sets: + - client + - diff + 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: enable-delete-protection + type: bool + description: | + Enable or disable delete protection for the namespace. When enabled, + the namespace cannot be deleted until this flag is set to false. + 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 lifecycle configuration is already set to the + specified value. Without this flag, the command errors when no change + is needed. + - name: resource-version + type: string + description: | + Resource version for optimistic concurrency control. If not provided, + the current version is fetched automatically. + +option-sets: + - name: client + options: + - name: api-key + type: string + implied-env: TEMPORAL_API_KEY + 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: diff + options: + - name: verbose-diff + type: bool + description: | + Show detailed differences between the current and desired namespace + configurations when changes are detected. + - name: common + external-package: github.com/temporalio/cli/cliext diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go new file mode 100644 index 0000000..4e7b3f5 --- /dev/null +++ b/temporalcloudcli/commands_test.go @@ -0,0 +1,242 @@ +//go:build integration +// +build integration + +package temporalcloudcli_test + +import ( + "bytes" + "context" + "fmt" + "io" + "math/rand" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/temporalio/cloud-cli/temporalcloudcli" + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" + "go.temporal.io/cloud-sdk/cloudclient" +) + +type CommandHarness struct { + *require.Assertions + t *testing.T + Options temporalcloudcli.CommandOptions + // Defaults to a context closed on close or test complete + Context context.Context + // Can be used to cancel context given to commands (simulating interrupt) + CancelContext context.CancelFunc + Stdin bytes.Buffer +} + +func NewCommandHarness(t *testing.T) *CommandHarness { + h := &CommandHarness{Assertions: require.New(t), t: t} + h.Context, h.CancelContext = context.WithCancel(context.Background()) + t.Cleanup(h.Close) + return h +} + +// Reentrant, called after test by default, cancels context +func (h *CommandHarness) Close() { + // Cancel context + if h.CancelContext != nil { + h.CancelContext() + } +} + +// Pieces must appear in order on the line and not overlap +func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) { + h.NoError(AssertContainsOnSameLine(text, pieces...)) +} + +func AssertContainsOnSameLine(text string, pieces ...string) error { + // Build regex pattern based on pieces + pattern := "" + for _, piece := range pieces { + if pattern != "" { + pattern += ".*" + } + pattern += regexp.QuoteMeta(piece) + } + regex, err := regexp.Compile(pattern) + if err != nil { + return err + } + // Split into lines, then check each piece is present + lines := strings.Split(text, "\n") + for _, line := range lines { + if regex.MatchString(line) { + return nil + } + } + return fmt.Errorf("pieces not found in order on any line together") +} + +func TestAssertContainsOnSameLine(t *testing.T) { + require.Error(t, AssertContainsOnSameLine("a b c", "b", "a")) + require.Error(t, AssertContainsOnSameLine("a\nb c", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("aba", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("a b a", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("axb", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) +} + +func (h *CommandHarness) Eventually( + condition func() bool, + waitFor time.Duration, + tick time.Duration, + msgAndArgs ...interface{}, +) { + h.t.Helper() + // We cannot use require.Eventually because it was poorly developed to run the + // condition function in a goroutine which means it can run after complete or + // have other race conditions. Don't even need a complicated ticker because it + // doesn't need to be interruptible. + for start := time.Now(); time.Since(start) < waitFor; { + if condition() { + return + } + time.Sleep(tick) + } + h.Fail("condition did not evaluate to true within timeout", msgAndArgs...) +} + +func (h *CommandHarness) T() *testing.T { + return h.t +} + +type CommandResult struct { + Err error + Stdout bytes.Buffer + Stderr bytes.Buffer +} + +func (h *CommandHarness) Execute(args ...string) *CommandResult { + // Copy options, update as needed + res := &CommandResult{} + options := h.Options + // Set stdio + options.Stdin = &h.Stdin + options.Stdout = &res.Stdout + options.Stderr = &res.Stderr + // Set args + options.Args = args + // Capture error + options.Fail = func(err error) { + if res.Err != nil { + panic("fail called twice, just failed with " + err.Error()) + } + res.Err = err + } + + // Run + ctx, cancel := context.WithCancel(h.Context) + h.t.Cleanup(cancel) + defer cancel() + h.t.Logf("Calling: %v", strings.Join(args, " ")) + temporalcloudcli.Execute(ctx, options) + if res.Stdout.Len() > 0 { + h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) + } + if res.Stderr.Len() > 0 { + h.t.Logf("Stderr:\n-----\n%s\n-----", &res.Stderr) + } + return res +} + +type EnvLookupMap map[string]string + +func (e EnvLookupMap) Environ() []string { + ret := make([]string, 0, len(e)) + for k := range e { + ret = append(ret, k) + } + return ret +} + +func (e EnvLookupMap) LookupEnv(key string) (string, bool) { + v, ok := e[key] + return v, ok +} + +// Run shared server suite +func TestSharedServerSuite(t *testing.T) { + suite.Run(t, new(SharedServerSuite)) +} + +type SharedServerSuite struct { + // Replaced each test + *CommandHarness + + Suite suite.Suite + + apiKey string + server string +} + +func (s *SharedServerSuite) SetupSuite() { + s.apiKey = os.Getenv("TEMPORAL_API_KEY") + s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_API_KEY. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") + s.server = os.Getenv("TEMPORAL_CLOUD_SERVER") + s.Suite.Require().NotEmpty(s.server, "Could not load TEMPORAL_CLOUD_SERVER. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") +} + +func (s *SharedServerSuite) TearDownSuite() { +} + +func (s *SharedServerSuite) SetupTest() { + // Create new command harness + s.CommandHarness = NewCommandHarness(s.Suite.T()) +} + +func (s *SharedServerSuite) TearDownTest() { + if s.CommandHarness != nil { + s.CommandHarness.Close() + } + s.CommandHarness = nil +} + +func (s *SharedServerSuite) T() *testing.T { return s.Suite.T() } +func (s *SharedServerSuite) SetT(t *testing.T) { s.Suite.SetT(t) } +func (s *SharedServerSuite) SetS(suite suite.TestingSuite) { s.Suite.SetS(suite) } + +func (s *SharedServerSuite) generateRandomID() string { + letters := "abcdefghijklmnopqrstuvwxyz123456789" + b := make([]byte, 10) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func (s *SharedServerSuite) getCloudClient() *cloudclient.Client { + opts := cloudclient.Options{ + APIKey: s.apiKey, + HostPort: s.server, + } + + cloudClient, err := cloudclient.New(opts) + s.Suite.Require().NoError(err) + return cloudClient +} + +// pollAsyncOperation polls an async operation until it reaches a terminal state. +// This is a test wrapper around the PollAsyncOperation function. +func (s *SharedServerSuite) pollAsyncOperation( + cloudClient *cloudclient.Client, + operationID string, +) error { + // Create a minimal CommandContext for testing (with discard printer to skip output) + cctx := &temporalcloudcli.CommandContext{ + Context: s.Context, + Printer: &printer.Printer{ + Output: io.Discard, // Discard all output for tests + }, + } + + return temporalcloudcli.PollAsyncOperation(cctx, cloudClient, operationID, "") +} diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go new file mode 100644 index 0000000..b35e44c --- /dev/null +++ b/temporalcloudcli/common.go @@ -0,0 +1,226 @@ +package temporalcloudcli + +import ( + "bytes" + "cmp" + "fmt" + "os" + "os/exec" + "strings" + "time" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + "go.temporal.io/cloud-sdk/cloudclient" + "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 filePath, ok := strings.CutPrefix(spec, "@"); ok { + 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 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) + } + + defer func() { + // Clean up temp file. + _ = os.Remove(f.Name()) + }() + + 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 +} + +func promptApplyResource(cctx *CommandContext, existing, actual proto.Message, verboseDiff bool) error { + if !cctx.JSONOutput { + cctx.Printer.PrintDiff(existing, actual, printer.DiffOptions{ + Verbose: verboseDiff, + }) + } + + yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + + if !yes { + return fmt.Errorf("Aborting apply.") + } + return nil +} + +// pollAsyncOperation polls an async operation until it reaches a terminal state. +// It prints status updates every second and returns the final AsyncOperation. +// +// The cloudClient should be pre-built using cctx.BuildCloudClient(). +// +// AIDEV-NOTE: This function takes a pre-built cloudClient. Commands should +// build the client using cctx.BuildCloudClient() and pass it directly. +func PollAsyncOperation( + cctx *CommandContext, + cloudClient *cloudclient.Client, + operationID string, + id string, +) error { + 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 + var progressString string + switch asyncOp.State { + case operation.AsyncOperation_STATE_PENDING: + progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_IN_PROGRESS: + progressString = fmt.Sprintf("[%s] Operation in progress...\n", time.Now().Format("15:04:05")) + case operation.AsyncOperation_STATE_FULFILLED: + progressString = fmt.Sprintf("[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}) + case operation.AsyncOperation_STATE_FAILED: + progressString = fmt.Sprintf("[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) + // Print the structured output first, then return error for proper exit code + if err := cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}); err != nil { + return err + } + return fmt.Errorf("async operation failed: %s", asyncOp.FailureReason) + case operation.AsyncOperation_STATE_CANCELLED: + progressString = fmt.Sprintf("[%s] Operation cancelled\n", time.Now().Format("15:04:05")) + // Print the structured output first, then return error for proper exit code + if err := cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}); err != nil { + return err + } + return fmt.Errorf("async operation cancelled") + case operation.AsyncOperation_STATE_REJECTED: + progressString = fmt.Sprintf("[%s] Operation rejected\n", time.Now().Format("15:04:05")) + // Print the structured output first, then return error for proper exit code + if err := cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}); err != nil { + return err + } + return fmt.Errorf("async operation rejected") + default: + progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) + } + if !cctx.JSONOutput { + cctx.Printer.Print(progressString) + } + } + } +} + +type MutationResult struct { + AsyncOp *operation.AsyncOperation `json:"asyncOperation"` + ID string `json:"id"` +} 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..669d5ac --- /dev/null +++ b/temporalcloudcli/internal/printer/printer_test.go @@ -0,0 +1,142 @@ +package printer_test + +import ( + "bytes" + "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()) +} diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go new file mode 100644 index 0000000..c9af2be --- /dev/null +++ b/temporalcloudcli/namespace.go @@ -0,0 +1,194 @@ +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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +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 +} + +type createNamespaceParams struct { + spec *namespace.NamespaceSpec + + asyncOperationID string +} + +type createNamespaceResponse struct { + asyncOp *operation.AsyncOperation + Namespace string +} + +func (c *namespaceClient) createNamespace(ctx context.Context, params createNamespaceParams) (createNamespaceResponse, error) { + res, err := c.client.CloudService().CreateNamespace(ctx, &cloudservice.CreateNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Spec: params.spec, + }) + if err != nil { + return createNamespaceResponse{}, err + } + + return createNamespaceResponse{ + asyncOp: res.GetAsyncOperation(), + Namespace: res.GetNamespace(), + }, 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) { + // get the namespace to get its resource version + ns, err := c.getNamespace(ctx, params.namespace) + if err != nil { + if isNotFoundErr(err) && params.idempotent { + return nil, nil + } + return nil, err + } + + if params.resourceVersion == "" { + params.resourceVersion = ns.ResourceVersion + } + + res, err := c.client.CloudService().DeleteNamespace(ctx, &cloudservice.DeleteNamespaceRequest{ + AsyncOperationId: params.asyncOperationID, + Namespace: params.namespace, + ResourceVersion: params.resourceVersion, + }) + if err != nil { + if isNotFoundErr(err) && params.idempotent { + return nil, nil + } + return nil, err + } + + return res.AsyncOperation, nil +} + +func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string) (*namespace.Namespace, error) { + namespaces, err := c.listNamespacesWithName(ctx, name, true) + if err != nil { + return nil, err + } else if len(namespaces) > 1 { + return nil, fmt.Errorf("multiple namespaces match namespace name: %s", name) + } else if len(namespaces) == 0 { + return nil, status.Errorf(codes.NotFound, "namespace not found") + } + return namespaces[0], nil +} + +func (c *namespaceClient) listNamespacesWithName(ctx context.Context, name string, shortCircuit bool) ([]*namespace.Namespace, error) { + namespaces := []*namespace.Namespace{} + pageToken := "" + for { + res, err := c.client.CloudService().GetNamespaces(ctx, &cloudservice.GetNamespacesRequest{ + Name: name, + PageToken: pageToken, + }) + if err != nil { + return nil, err + } + namespaces = append(namespaces, res.Namespaces...) + if shortCircuit { + return namespaces, nil + } + // Check if we should continue paging + pageToken = res.NextPageToken + if len(pageToken) == 0 { + break + } + } + return namespaces, nil +} diff --git a/temporalcloudcli/oauth.go b/temporalcloudcli/oauth.go new file mode 100644 index 0000000..f3b3b6a --- /dev/null +++ b/temporalcloudcli/oauth.go @@ -0,0 +1,155 @@ +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. + // AIDEV-NOTE: serverErrCh captures server startup errors to prevent silent failures + var once sync.Once + resultCh := make(chan oauthCallbackResult, 1) + serverErrCh := make(chan error, 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.") + }) + }), + } + // AIDEV-NOTE: Start server in goroutine and capture startup errors + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErrCh <- fmt.Errorf("failed to start OAuth callback server: %w", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + server.Shutdown(shutdownCtx) + }() + + // Wait for callback result, server error, or context cancellation. + var result oauthCallbackResult + select { + case result = <-resultCh: + case err := <-serverErrCh: + return nil, err + 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 +}