Gemini CLI authentication
+{{.}}
+ +diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d207b180 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.go text eol=lf diff --git a/.github/workflows/release-go-sdk.yml b/.github/workflows/release-go-sdk.yml index 882f3b2b..315fe6cf 100644 --- a/.github/workflows/release-go-sdk.yml +++ b/.github/workflows/release-go-sdk.yml @@ -9,6 +9,11 @@ on: permissions: contents: read +env: + # Release checks must exercise the tagged modules exactly as consumers see + # them, without resolving sibling modules through the repository workspace. + GOWORK: "off" + concurrency: group: release-go-sdk-${{ github.ref_name }} cancel-in-progress: false diff --git a/.github/workflows/validate-gemini-markets-cli.yml b/.github/workflows/validate-gemini-markets-cli.yml new file mode 100644 index 00000000..aaf64465 --- /dev/null +++ b/.github/workflows/validate-gemini-markets-cli.yml @@ -0,0 +1,81 @@ +name: Validate Gemini Markets CLI + +on: + pull_request: + paths: + - "packages/gemini-markets-cli/**" + - "packages/sdk-go/**" + - "go.work" + - "go.work.sum" + - ".github/workflows/validate-gemini-markets-cli.yml" + +permissions: + contents: read + +jobs: + validate: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-14 + - windows-2022 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + # The root workspace includes the SDK generator module, whose pinned Go + # version defines the monorepo toolchain used by this integration check. + GOTOOLCHAIN: local + defaults: + run: + working-directory: packages/gemini-markets-cli + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: packages/sdk-go/scripts/go.mod + cache: true + cache-dependency-path: | + go.work + go.work.sum + packages/gemini-markets-cli/go.mod + packages/gemini-markets-cli/go.sum + packages/sdk-go/go.mod + packages/sdk-go/go.sum + packages/sdk-go/cmd/demo/go.mod + packages/sdk-go/cmd/demo/go.sum + packages/sdk-go/scripts/go.mod + packages/sdk-go/scripts/go.sum + packages/sdk-go/websocket/gorilla/go.mod + packages/sdk-go/websocket/gorilla/go.sum + samples/go/go.mod + samples/go/go.sum + + - name: Verify module dependencies + run: go mod verify + - name: Check formatting + shell: bash + run: | + files="$(gofmt -l .)" + if [[ -n "$files" ]]; then + echo "The following files require gofmt:" + echo "$files" + exit 1 + fi + - name: Run tests + run: go test ./... + - name: Run race detector + run: go test -race ./... + - name: Run vet + run: go vet ./... + - name: Build command + run: go build -o "${{ runner.temp }}/gemini-markets" ./cmd/gemini-markets + - name: Smoke-test help + run: go run ./cmd/gemini-markets --help + - name: Check package documentation + run: go doc ./internal/cli diff --git a/.github/workflows/validate-go-sdk.yml b/.github/workflows/validate-go-sdk.yml index fffe0ba6..60493791 100644 --- a/.github/workflows/validate-go-sdk.yml +++ b/.github/workflows/validate-go-sdk.yml @@ -10,6 +10,11 @@ on: permissions: contents: read +env: + # Validate each published module in isolation. The repository workspace is + # for monorepo development and must not mask missing module requirements. + GOWORK: "off" + jobs: minimum-go: runs-on: ubuntu-24.04 diff --git a/go.work b/go.work new file mode 100644 index 00000000..105b4f33 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.25.13 + +use ( + ./packages/gemini-markets-cli + ./packages/sdk-go + ./packages/sdk-go/cmd/demo + ./packages/sdk-go/scripts + ./packages/sdk-go/websocket/gorilla + ./samples/go +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 00000000..d4005be9 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,10 @@ +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/packages/gemini-markets-cli/.gitignore b/packages/gemini-markets-cli/.gitignore new file mode 100644 index 00000000..6260ce7f --- /dev/null +++ b/packages/gemini-markets-cli/.gitignore @@ -0,0 +1,7 @@ +# Local CLI build output. +/bin/ +/gemini-markets +/gemini-markets.exe + +# GoReleaser snapshot/release output. +/dist/ diff --git a/packages/gemini-markets-cli/.goreleaser.yml b/packages/gemini-markets-cli/.goreleaser.yml new file mode 100644 index 00000000..9996cbeb --- /dev/null +++ b/packages/gemini-markets-cli/.goreleaser.yml @@ -0,0 +1,52 @@ +version: 2 + +project_name: gemini-markets + +# Run GoReleaser from this module directory in a full repository checkout. The +# root go.work resolves the CLI against the sibling SDK modules at this commit. +before: + hooks: + - go mod verify + +builds: + - id: gemini-markets + main: ./cmd/gemini-markets + binary: gemini-markets + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/cli.Version={{ .Version }} + mod_timestamp: '{{ .CommitTimestamp }}' + +archives: + - id: gemini-markets + ids: + - gemini-markets + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt' + +snapshot: + version_template: '{{ incpatch .Version }}-next' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/packages/gemini-markets-cli/Makefile b/packages/gemini-markets-cli/Makefile new file mode 100644 index 00000000..d4a34a21 --- /dev/null +++ b/packages/gemini-markets-cli/Makefile @@ -0,0 +1,38 @@ +GO ?= go +BINARY ?= gemini-markets +BUILD_DIR ?= ./bin + +.PHONY: all build check test race vet docs smoke fmt mod-verify + +all: check + +build: + mkdir -p "$(BUILD_DIR)" + $(GO) build -trimpath -o "$(BUILD_DIR)/$(BINARY)" ./cmd/gemini-markets + +test: + $(GO) test ./... + +race: + $(GO) test -race ./... + +vet: + $(GO) vet ./... + +docs: + $(GO) doc ./cmd/gemini-markets >/dev/null + $(GO) doc ./internal/cli >/dev/null + $(GO) doc ./internal/credentials >/dev/null + $(GO) doc ./internal/output >/dev/null + $(GO) doc ./internal/session >/dev/null + +smoke: + $(GO) run ./cmd/gemini-markets --help >/dev/null + +fmt: + @test -z "$$(gofmt -l .)" || (echo "gofmt required; run gofmt -w ." && exit 1) + +mod-verify: + $(GO) mod verify + +check: fmt mod-verify test race vet build docs smoke diff --git a/packages/gemini-markets-cli/README.md b/packages/gemini-markets-cli/README.md new file mode 100644 index 00000000..ef8d9a18 --- /dev/null +++ b/packages/gemini-markets-cli/README.md @@ -0,0 +1,174 @@ +# Gemini Markets CLI + +`gemini-markets` is a small command-line client for the Gemini exchange. It +uses the official [Go SDK](../sdk-go/README.md) for authentication, REST and +WebSocket transport, generated API models, and protocol behavior. The CLI owns +command-line parsing, validation, and table/JSON rendering; it does not copy +SDK protocol code. + +## Status and command shape + +The root command registers all groups shown below. Check +`gemini-markets --help` for the available commands. + +```text +gemini-markets +├── version +├── completion +├── help +├── auth +│ ├── login +│ ├── logout +│ └── status +├── markets +│ ├── symbols +│ ├── ticker SYMBOL +│ ├── book SYMBOL +│ └── candles SYMBOL TIMEFRAME +├── prediction-markets +│ ├── list +│ ├── get EVENT_TICKER +│ └── terms +│ ├── show +│ ├── status +│ └── accept +├── stream +│ ├── trades SYMBOL +│ ├── ticker SYMBOL +│ ├── depth SYMBOL +│ ├── orders +│ ├── balances +│ └── positions +├── account +│ └── balances +└── orders + ├── spot {place,list,get,cancel} + └── prediction {place,list,cancel} +``` + +Global options are `--environment production|sandbox` (default +`production`), `--profile` (default `default`), and `--output table|json` +(default `table`). Public discovery commands can use an empty credential set; +terms status/acceptance, account and order commands, plus private streams, +require credentials accepted by the selected SDK service. Stream commands emit +NDJSON (one JSON event per line); the global `--output` flag does not apply to +streams. + +`markets candles` accepts `1m`, `5m`, `15m`, `30m`, `1h`, `6h`, and `1d`. +The canonical API spellings `1hr`, `6hr`, and `1day` are also accepted; the CLI +normalizes the shorter forms before calling the SDK. +Spot orders are limit orders (or limit orders with `--option immediate-or-cancel` +for IOC); direct `exchange market` orders are not supported by this CLI. +Prediction-market terms are never accepted automatically: check +`prediction-markets terms status`, read `prediction-markets terms show`, and +invoke `prediction-markets terms accept --yes` explicitly when required before +placing an order. + +Order placement supports `--dry-run`, which validates and renders the exact +request without loading credentials or contacting Gemini. In table mode, the +preview includes execution options such as IOC and maker-or-cancel. Stop-limit +orders are also checked locally against the side-specific trigger/limit price +relationship before either a preview or submission proceeds. + +## Credentials + +API commands resolve one complete credential family from the process +environment first, then the selected operating-system keyring profile. The +`auth` command group is the secure persistence boundary: its login and logout +flows use the operating-system keyring. Public requests work without +credentials. Never put secrets in source control or command-line arguments. + +| Variable | Use | +| --- | --- | +| `GEMINI_API_KEY` | HMAC API key, with `GEMINI_API_SECRET` | +| `GEMINI_API_SECRET` | HMAC API secret, with `GEMINI_API_KEY` | +| `GEMINI_ACCESS_TOKEN` | OAuth/bearer access token | +| `GEMINI_BEARER_TOKEN` | Alias for `GEMINI_ACCESS_TOKEN` when the latter is unset | +| `GEMINI_REFRESH_TOKEN` | OAuth refresh token | +| `GEMINI_OAUTH_CLIENT_ID` | OAuth client ID; required with a refresh token | +| `GEMINI_OAUTH_CLIENT_SECRET` | Confidential OAuth client secret | + +When both HMAC and token material are present, token authentication takes +precedence in the SDK session. A public request does not require credentials; +private requests should be run against the intended environment explicitly. +For private commands, set an access token alone or use `auth login`: refresh +tokens supplied through environment variables are rejected because a rotated +token cannot be persisted safely back to that source. + +## Build and validate locally + +Requirements: a checkout of the full repository and the Go version declared by +`packages/sdk-go/scripts/go.mod` (currently 1.25.13). The CLI module itself +uses the Go 1.23 language baseline, but the checked-in root `go.work` also +includes the SDK generator module and therefore sets the toolchain for +monorepo development. + +The CLI's `go.mod` contains ordinary `v0.1.0` SDK requirements and no local +`replace` directives. Within this repository, the root workspace intentionally +resolves those imports to the sibling `packages/sdk-go` modules at the same +checkout. That keeps the CLI on the current monorepo SDK while preserving valid +module metadata. Run CLI development and release builds from the full checkout; +standalone `go install` is not a supported installation path yet. + +From this directory: + +```bash +make test +make race +make vet +make docs +make build # writes bin/gemini-markets +make check # all of the above, including gofmt and mod verify +``` + +Equivalent direct commands are: + +```bash +go mod verify +go test ./... +go test -race ./... +go vet ./... +mkdir -p bin +go build -o bin/gemini-markets ./cmd/gemini-markets +``` + +`go doc ./internal/cli` is the documentation check used by CI. Network access +or a populated Go module cache may be needed on the first run to resolve the +SDK's dependencies. + +## Release status + +The CLI is pre-release: it is not available from the Go proxy, has no +published binary release, and is not ready for `go install`. There is no CLI +release workflow yet. + +Both SDK modules are publicly available from `proxy.golang.org` at annotated +`v0.1.0` tags: + +```text +packages/sdk-go/v0.1.0 +packages/sdk-go/websocket/gorilla/v0.1.0 +``` + +This CLI also depends on newer SDK changes in the current checkout, including +OAuth token-update persistence and corrected service behavior. Those SDK +changes should be split into an SDK-first commit/PR and merged before the CLI, +so the SDK remains independently releasable. The CLI will continue to consume +the sibling SDK through `go.work`; publishing another SDK tag is only required +before supporting standalone module consumers outside the monorepo. + +After the SDK prerequisite lands, design the CLI release workflow around +module-prefixed tags (for example, +`packages/gemini-markets-cli/v0.1.0`). Use a new signed, annotated tag on a +commit merged to `main`; never move a published version tag. + +`.goreleaser.yml` is an OSS-compatible snapshot-build draft for Linux, macOS, +and Windows (`amd64` and `arm64`). Run it from this module directory; its +artifacts are written to the ignored `dist/` directory: + +```bash +goreleaser release --config .goreleaser.yml --snapshot --clean +``` + +This snapshot uses the repository workspace and does not publish or imply +release readiness. diff --git a/packages/gemini-markets-cli/cmd/gemini-markets/main.go b/packages/gemini-markets-cli/cmd/gemini-markets/main.go new file mode 100644 index 00000000..fbba45c8 --- /dev/null +++ b/packages/gemini-markets-cli/cmd/gemini-markets/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/cli" + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + command := cli.NewRootCommand(os.Stdout, os.Stderr) + if err := command.ExecuteContext(ctx); err != nil { + format := cli.Options(command).Format + if writeErr := output.WriteError(os.Stderr, err, format); writeErr != nil { + _, _ = os.Stderr.WriteString(err.Error() + "\n") + } + os.Exit(1) + } +} diff --git a/packages/gemini-markets-cli/go.mod b/packages/gemini-markets-cli/go.mod new file mode 100644 index 00000000..38c83d60 --- /dev/null +++ b/packages/gemini-markets-cli/go.mod @@ -0,0 +1,24 @@ +module github.com/gemini/developer-platform/packages/gemini-markets-cli + +go 1.23 + +require ( + github.com/99designs/keyring v1.2.2 + github.com/gemini/developer-platform/packages/sdk-go v0.1.0 + github.com/gemini/developer-platform/packages/sdk-go/websocket/gorilla v0.1.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/danieljoos/wincred v1.1.2 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mtibben/percent v0.2.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.3.0 // indirect + golang.org/x/term v0.3.0 // indirect +) diff --git a/packages/gemini-markets-cli/go.sum b/packages/gemini-markets-cli/go.sum new file mode 100644 index 00000000..cf31a813 --- /dev/null +++ b/packages/gemini-markets-cli/go.sum @@ -0,0 +1,51 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/gemini-markets-cli/internal/cli/auth.go b/packages/gemini-markets-cli/internal/cli/auth.go new file mode 100644 index 00000000..4307375c --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/auth.go @@ -0,0 +1,295 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/credentials" + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + gemini "github.com/gemini/developer-platform/packages/sdk-go" + geminioauth "github.com/gemini/developer-platform/packages/sdk-go/oauth" + "github.com/spf13/cobra" +) + +const ( + defaultOAuthClientID = "6a03a47b-1bb4-491a-b0a7-35ad17473e71" + oauthRedirectURL = "http://localhost:8787/callback" +) + +var defaultOAuthScopes = []string{"account:read", "balances:read", "orders:read", "orders:create", "history:read"} + +// OAuthConfigFactory constructs the SDK OAuth configuration for one CLI +// invocation. It is injectable so command tests never need to contact the +// authorization service. +type OAuthConfigFactory func(GlobalOptions) (geminioauth.Config, error) + +// OAuthLoginFunc runs the official SDK's PKCE loopback login. The seam keeps +// browser and process behavior out of command tests while production always +// delegates to oauth.Config.Login. +type OAuthLoginFunc func(context.Context, geminioauth.Config, geminioauth.BrowserOpener) (*geminioauth.Token, error) + +// AuthCommandDependencies contains the side effects owned by auth commands. +// A nil Keyring, Browser, OAuthConfigFactory, or OAuthLogin uses the secure +// production implementation. +type AuthCommandDependencies struct { + Keyring credentials.Keyring + Browser geminioauth.BrowserOpener + OAuthConfigFactory OAuthConfigFactory + OAuthLogin OAuthLoginFunc +} + +// NewAuthCommand creates the auth command group. +// +// The optional dependency value exists for focused tests and embedders. Root +// command registration remains the caller's responsibility: +// root.AddCommand(NewAuthCommand()). +func NewAuthCommand(dependencies ...AuthCommandDependencies) *cobra.Command { + var dependency AuthCommandDependencies + if len(dependencies) > 0 { + dependency = dependencies[0] + } + + command := &cobra.Command{ + Use: "auth", + Short: "Manage Gemini authentication", + Args: cobra.NoArgs, + } + command.AddCommand( + newAuthLoginCommand(dependency), + newAuthLogoutCommand(dependency), + newAuthStatusCommand(dependency), + ) + return command +} + +// NewAuthCommandWithDependencies is the explicit dependency-injection form +// of NewAuthCommand. +func NewAuthCommandWithDependencies(dependencies AuthCommandDependencies) *cobra.Command { + return NewAuthCommand(dependencies) +} + +func newAuthLoginCommand(dependency AuthCommandDependencies) *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Authorize with OAuth and save credentials", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + options := Options(command) + configFactory := dependency.OAuthConfigFactory + if configFactory == nil { + configFactory = defaultOAuthConfig + } + config, err := configFactory(options) + if err != nil { + return fmt.Errorf("configure OAuth login: %w", err) + } + + login := dependency.OAuthLogin + if login == nil { + login = func(ctx context.Context, config geminioauth.Config, browser geminioauth.BrowserOpener) (*geminioauth.Token, error) { + return config.Login(ctx, browser) + } + } + browser := dependency.Browser + if browser == nil { + browser = openAuthBrowser + } + token, err := login(command.Context(), config, browser) + if err != nil { + return fmt.Errorf("OAuth login failed: %w", err) + } + if token == nil || strings.TrimSpace(token.AccessToken) == "" { + return errors.New("OAuth login returned no access token") + } + + keyring := dependency.Keyring + if keyring == nil { + keyring, err = credentials.NewOSKeyring() + if err != nil { + return fmt.Errorf("open credential keyring: %w", err) + } + } + value := credentials.Credentials{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + OAuthClientID: config.ClientID, + OAuthClientSecret: config.ClientSecret, + ExpiresAt: token.ExpiresAt, + } + if err := keyring.Set(command.Context(), credentialProfile(options), value); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + return writeAuthMessage(command.OutOrStdout(), options.Format, authMessage{ + Profile: options.Profile, + Message: "OAuth login completed; credentials saved securely", + }) + }, + } +} + +func newAuthLogoutCommand(dependency AuthCommandDependencies) *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove saved credentials", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + options := Options(command) + keyring := dependency.Keyring + if keyring == nil { + var err error + keyring, err = credentials.NewOSKeyring() + if err != nil { + if errors.Is(err, credentials.ErrUnavailable) { + return writeAuthMessage(command.OutOrStdout(), options.Format, authMessage{ + Profile: options.Profile, + Message: "no saved credentials found", + }) + } + return fmt.Errorf("open credential keyring: %w", err) + } + } + err := keyring.Delete(command.Context(), credentialProfile(options)) + if err != nil && !errors.Is(err, credentials.ErrNotFound) { + return fmt.Errorf("remove saved credentials: %w", err) + } + return writeAuthMessage(command.OutOrStdout(), options.Format, authMessage{ + Profile: options.Profile, + Message: "saved credentials removed", + }) + }, + } +} + +func newAuthStatusCommand(dependency AuthCommandDependencies) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show authentication status without secrets", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + options := Options(command) + loadOptions := credentials.LoadOptions{Profile: credentialProfile(options)} + if dependency.Keyring != nil { + loadOptions.Keyring = dependency.Keyring + } + value, err := credentials.Load(command.Context(), loadOptions) + if err != nil { + return fmt.Errorf("load credentials: %w", err) + } + return writeAuthStatus(command.OutOrStdout(), options.Format, newAuthStatus(options, value)) + }, + } +} + +type authMessage struct { + Profile string `json:"profile"` + Message string `json:"message"` +} + +func writeAuthMessage(writer io.Writer, format output.Format, message authMessage) error { + if format == output.JSON { + return output.Write(writer, message, format) + } + return output.Write(writer, output.TableData{ + Headers: []string{"PROFILE", "STATUS"}, + Rows: [][]string{{message.Profile, message.Message}}, + }, format) +} + +// AuthStatus is the secret-free representation emitted by auth status. It +// reports only presence and authentication mode, never credential contents. +type AuthStatus struct { + Profile string `json:"profile"` + Environment string `json:"environment"` + Configured bool `json:"configured"` + Authentication string `json:"authentication"` + APIKeyPresent bool `json:"api_key_present"` + APISecretPresent bool `json:"api_secret_present"` + AccessTokenPresent bool `json:"access_token_present"` + RefreshTokenPresent bool `json:"refresh_token_present"` +} + +func newAuthStatus(options GlobalOptions, value credentials.Credentials) AuthStatus { + authentication := "none" + switch { + case value.AccessToken != "" || value.RefreshToken != "": + if value.RefreshToken != "" { + authentication = "oauth" + } else { + authentication = "bearer" + } + case value.APIKey != "" || value.APISecret != "": + authentication = "hmac" + } + return AuthStatus{ + Profile: options.Profile, + Environment: options.Environment, + Configured: authentication != "none", + Authentication: authentication, + APIKeyPresent: value.APIKey != "", + APISecretPresent: value.APISecret != "", + AccessTokenPresent: value.AccessToken != "", + RefreshTokenPresent: value.RefreshToken != "", + } +} + +func writeAuthStatus(writer io.Writer, format output.Format, status AuthStatus) error { + if format == output.JSON { + return output.Write(writer, status, format) + } + authentication := status.Authentication + if authentication == "none" { + authentication = "not configured" + } + configured := "no" + if status.Configured { + configured = "yes" + } + return output.Write(writer, output.TableData{ + Headers: []string{"PROFILE", "ENVIRONMENT", "CONFIGURED", "AUTHENTICATION"}, + Rows: [][]string{{status.Profile, status.Environment, configured, authentication}}, + }, format) +} + +func defaultOAuthConfig(options GlobalOptions) (geminioauth.Config, error) { + environment := gemini.Environment(strings.ToLower(strings.TrimSpace(options.Environment))) + if environment == "" { + environment = gemini.Production + } + endpoints, ok := gemini.EndpointsFor(environment) + if !ok { + return geminioauth.Config{}, fmt.Errorf("unknown Gemini environment %q", environment) + } + clientID := strings.TrimSpace(os.Getenv("GEMINI_OAUTH_CLIENT_ID")) + if clientID == "" { + clientID = defaultOAuthClientID + } + return geminioauth.Config{ + ClientID: clientID, + ClientSecret: strings.TrimSpace(os.Getenv("GEMINI_OAUTH_CLIENT_SECRET")), + Endpoint: geminioauth.Endpoint{ + AuthURL: endpoints.OAuthAuthorization, + TokenURL: endpoints.OAuthToken, + }, + RedirectURL: oauthRedirectURL, + Scopes: append([]string(nil), defaultOAuthScopes...), + }, nil +} + +func openAuthBrowser(rawURL string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", rawURL).Start() + case "linux": + return exec.Command("xdg-open", rawURL).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() + default: + return fmt.Errorf("unsupported operating system %q", runtime.GOOS) + } +} diff --git a/packages/gemini-markets-cli/internal/cli/auth_test.go b/packages/gemini-markets-cli/internal/cli/auth_test.go new file mode 100644 index 00000000..a61b993c --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/auth_test.go @@ -0,0 +1,244 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/credentials" + geminioauth "github.com/gemini/developer-platform/packages/sdk-go/oauth" + "github.com/spf13/cobra" +) + +type memoryCredentialsKeyring struct { + values map[string]credentials.Credentials + gets int + sets int + deletes int +} + +func (k *memoryCredentialsKeyring) Get(_ context.Context, profile string) (credentials.Credentials, error) { + k.gets++ + value, ok := k.values[profile] + if !ok { + return credentials.Credentials{}, credentials.ErrNotFound + } + return value, nil +} + +func (k *memoryCredentialsKeyring) Set(_ context.Context, profile string, value credentials.Credentials) error { + if k.values == nil { + k.values = make(map[string]credentials.Credentials) + } + k.values[profile] = value + k.sets++ + return nil +} + +func (k *memoryCredentialsKeyring) Delete(_ context.Context, profile string) error { + if _, ok := k.values[profile]; !ok { + return credentials.ErrNotFound + } + delete(k.values, profile) + k.deletes++ + return nil +} + +func authTestCommand(dependencies AuthCommandDependencies) *cobra.Command { + command := NewAuthCommand(dependencies) + command.PersistentFlags().StringP("environment", "e", "production", "environment") + command.PersistentFlags().StringP("profile", "p", "default", "profile") + command.PersistentFlags().String("output", "table", "output") + return command +} + +func TestAuthLoginUsesSDKOAuthAndStoresOnlyAfterSuccess(t *testing.T) { + keyring := &memoryCredentialsKeyring{} + var gotConfig geminioauth.Config + var gotBrowser bool + command := authTestCommand(AuthCommandDependencies{ + Keyring: keyring, + OAuthConfigFactory: func(options GlobalOptions) (geminioauth.Config, error) { + if options.Environment != "sandbox" || options.Profile != "work" { + t.Fatalf("OAuthConfigFactory options = %#v", options) + } + return geminioauth.Config{ClientID: "client-id", ClientSecret: "client-secret"}, nil + }, + Browser: func(string) error { + gotBrowser = true + return nil + }, + OAuthLogin: func(_ context.Context, config geminioauth.Config, browser geminioauth.BrowserOpener) (*geminioauth.Token, error) { + gotConfig = config + if browser == nil { + t.Fatal("OAuthLogin received nil browser opener") + } + return &geminioauth.Token{ + AccessToken: "access-secret", RefreshToken: "refresh-secret", + ExpiresAt: time.Date(2026, time.August, 29, 12, 0, 0, 0, time.UTC), + }, nil + }, + }) + var output strings.Builder + command.SetOut(&output) + command.SetArgs([]string{"--environment", "sandbox", "--profile", "work", "login"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if gotBrowser { + t.Fatal("injected OAuthLogin should own browser invocation in this test") + } + if gotConfig.ClientID != "client-id" || gotConfig.ClientSecret != "client-secret" { + t.Fatalf("OAuthLogin config = %#v", gotConfig) + } + if keyring.sets != 1 { + t.Fatalf("keyring.Set calls = %d, want 1", keyring.sets) + } + got := keyring.values["work@sandbox"] + if got.AccessToken != "access-secret" || got.RefreshToken != "refresh-secret" || got.OAuthClientID != "client-id" || got.OAuthClientSecret != "client-secret" || got.ExpiresAt.IsZero() { + t.Fatalf("stored credentials = %#v", got) + } + if strings.Contains(output.String(), "access-secret") || strings.Contains(output.String(), "refresh-secret") || strings.Contains(output.String(), "client-secret") { + t.Fatalf("login output exposed secret: %q", output.String()) + } +} + +func TestAuthLoginDoesNotPersistFailedOAuth(t *testing.T) { + keyring := &memoryCredentialsKeyring{} + loginErr := errors.New("authorization denied") + command := authTestCommand(AuthCommandDependencies{ + Keyring: keyring, + OAuthLogin: func(context.Context, geminioauth.Config, geminioauth.BrowserOpener) (*geminioauth.Token, error) { + return nil, loginErr + }, + }) + command.SetArgs([]string{"login"}) + if err := command.Execute(); !errors.Is(err, loginErr) { + t.Fatalf("Execute() error = %v, want %v", err, loginErr) + } + if keyring.sets != 0 { + t.Fatalf("keyring.Set calls = %d, want 0", keyring.sets) + } +} + +func TestAuthStatusIsSecretFree(t *testing.T) { + keyring := &memoryCredentialsKeyring{values: map[string]credentials.Credentials{ + "default@production": {AccessToken: "access-secret", RefreshToken: "refresh-secret", OAuthClientID: "client-id"}, + }} + command := authTestCommand(AuthCommandDependencies{Keyring: keyring}) + var output strings.Builder + command.SetOut(&output) + command.SetArgs([]string{"--output", "json", "status"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if strings.Contains(output.String(), "access-secret") || strings.Contains(output.String(), "refresh-secret") || strings.Contains(output.String(), "client-id") { + t.Fatalf("status output exposed secret: %q", output.String()) + } + var got AuthStatus + if err := json.Unmarshal([]byte(output.String()), &got); err != nil { + t.Fatalf("status JSON error = %v", err) + } + if !got.Configured || got.Authentication != "oauth" || !got.AccessTokenPresent || !got.RefreshTokenPresent { + t.Fatalf("status = %#v, want configured OAuth presence", got) + } +} + +func TestAuthStatusReportsHMACPresenceWithoutValues(t *testing.T) { + keyring := &memoryCredentialsKeyring{values: map[string]credentials.Credentials{ + "default@production": {APIKey: "api-key-secret", APISecret: "api-secret"}, + }} + command := authTestCommand(AuthCommandDependencies{Keyring: keyring}) + var output strings.Builder + command.SetOut(&output) + command.SetArgs([]string{"status"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(output.String(), "hmac") || strings.Contains(output.String(), "api-key-secret") || strings.Contains(output.String(), "api-secret") { + t.Fatalf("status output = %q", output.String()) + } +} + +func TestAuthStatusDoesNotTreatOAuthClientMetadataAsLogin(t *testing.T) { + status := newAuthStatus(GlobalOptions{Profile: "default", Environment: "production"}, credentials.Credentials{OAuthClientID: "client-id"}) + if status.Configured || status.Authentication != "none" { + t.Fatalf("status = %#v, want no authentication", status) + } +} + +func TestAuthLogoutRemovesSelectedProfile(t *testing.T) { + keyring := &memoryCredentialsKeyring{values: map[string]credentials.Credentials{ + "work@production": {AccessToken: "access-secret"}, + }} + command := authTestCommand(AuthCommandDependencies{Keyring: keyring}) + var output strings.Builder + command.SetOut(&output) + command.SetArgs([]string{"--profile", "work", "logout"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if keyring.deletes != 1 { + t.Fatalf("keyring.Delete calls = %d, want 1", keyring.deletes) + } + if _, ok := keyring.values["work@production"]; ok { + t.Fatal("logout left selected profile credentials") + } + if strings.Contains(output.String(), "access-secret") { + t.Fatalf("logout output exposed secret: %q", output.String()) + } +} + +func TestAuthStatusDoesNotCrossEnvironmentNamespaces(t *testing.T) { + keyring := &memoryCredentialsKeyring{values: map[string]credentials.Credentials{ + "default@sandbox": {AccessToken: "sandbox-token"}, + }} + command := authTestCommand(AuthCommandDependencies{Keyring: keyring}) + command.SetArgs([]string{"--environment", "production", "status"}) + var output strings.Builder + command.SetOut(&output) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if strings.Contains(output.String(), "oauth") || strings.Contains(output.String(), "bearer") { + t.Fatalf("status crossed environment namespace: %q", output.String()) + } +} + +func TestAuthLogoutIsIdempotentForMissingProfile(t *testing.T) { + command := authTestCommand(AuthCommandDependencies{Keyring: &memoryCredentialsKeyring{values: map[string]credentials.Credentials{}}}) + command.SetArgs([]string{"logout"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v, want idempotent success", err) + } +} + +func TestDefaultOAuthConfigUsesEnvironmentEndpoints(t *testing.T) { + t.Setenv("GEMINI_OAUTH_CLIENT_ID", "custom-client") + t.Setenv("GEMINI_OAUTH_CLIENT_SECRET", "custom-secret") + config, err := defaultOAuthConfig(GlobalOptions{Environment: "sandbox"}) + if err != nil { + t.Fatalf("defaultOAuthConfig() error = %v", err) + } + if config.ClientID != "custom-client" || config.ClientSecret != "custom-secret" { + t.Fatalf("config client credentials = %#v", config) + } + if !strings.Contains(config.Endpoint.AuthURL, "sandbox") || !strings.Contains(config.Endpoint.TokenURL, "sandbox") { + t.Fatalf("config endpoints = %#v", config.Endpoint) + } + if !containsString(config.Scopes, "orders:create") { + t.Fatalf("OAuth scopes = %#v, want orders:create", config.Scopes) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/packages/gemini-markets-cli/internal/cli/private_account.go b/packages/gemini-markets-cli/internal/cli/private_account.go new file mode 100644 index 00000000..1e02412e --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_account.go @@ -0,0 +1,144 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + "github.com/gemini/developer-platform/packages/sdk-go/generated/account" + sdktypes "github.com/gemini/developer-platform/packages/sdk-go/types" + "github.com/spf13/cobra" +) + +// AccountBalancesClient is the narrow part of the SDK AccountService used by +// the balances command. Keeping this interface at the consumer boundary +// makes command tests independent from transport and credentials. +type AccountBalancesClient interface { + GetBalances(context.Context, *account.GetAvailableBalancesJSONBody) ([]account.Balance, error) +} + +// AccountBalancesFactory creates the authenticated account service and its +// owner. The owner is closed after each command invocation. +type AccountBalancesFactory func(context.Context, GlobalOptions) (AccountBalancesClient, io.Closer, error) + +func defaultAccountBalancesFactory(ctx context.Context, options GlobalOptions) (AccountBalancesClient, io.Closer, error) { + value, err := newPrivateSession(ctx, options) + if err != nil { + return nil, nil, err + } + return value.Client.Account, closeSession(value), nil +} + +// NewAccountCommand creates the authenticated account command group. +// +// Root command wiring intentionally remains in root.go's owner: callers should +// add the returned command with root.AddCommand(NewAccountCommand()). +func NewAccountCommand(factories ...AccountBalancesFactory) *cobra.Command { + if len(factories) > 0 && factories[0] != nil { + return NewAccountCommandWithFactory(factories[0]) + } + return NewAccountCommandWithFactory(defaultAccountBalancesFactory) +} + +// NewAccountCommandWithFactory creates the account command with an injected +// SDK consumer. It is primarily useful for focused command tests. +func NewAccountCommandWithFactory(factory AccountBalancesFactory) *cobra.Command { + if factory == nil { + factory = defaultAccountBalancesFactory + } + + accountCommand := &cobra.Command{ + Use: "account", + Aliases: []string{"accounts"}, + Short: "Authenticated account operations", + Args: cobra.NoArgs, + } + accountCommand.AddCommand(newBalancesCommand(factory)) + return accountCommand +} + +// NewAccountBalancesCommand is a convenience constructor for callers that +// want to register only the balances leaf command. +func NewAccountBalancesCommand(factories ...AccountBalancesFactory) *cobra.Command { + if len(factories) > 0 && factories[0] != nil { + return newBalancesCommand(factories[0]) + } + return newBalancesCommand(defaultAccountBalancesFactory) +} + +func newBalancesCommand(factory AccountBalancesFactory) *cobra.Command { + var accountName string + var showPending bool + + command := &cobra.Command{ + Use: "balances", + Short: "Show available balances", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + accountName = strings.TrimSpace(accountName) + if accountName == "" { + return fmt.Errorf("account is required") + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create account client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("account service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + request := &account.GetAvailableBalancesJSONBody{Account: accountName} + if showPending { + request.ShowPendingBalances = &showPending + } + balances, err := client.GetBalances(cmd.Context(), request) + if err != nil { + return fmt.Errorf("get account balances: %w", err) + } + return writeBalances(cmd.OutOrStdout(), balances, Options(cmd).Format) + }, + } + command.Flags().StringVarP(&accountName, "account", "a", "primary", "account or subaccount name") + command.Flags().BoolVar(&showPending, "show-pending-balances", false, "include pending deposits and withdrawals") + return command +} + +func writeBalances(w io.Writer, balances []account.Balance, format output.Format) error { + if format == output.JSON { + return output.Write(w, balances, format) + } + rows := make([][]string, 0, len(balances)) + for _, balance := range balances { + rows = append(rows, []string{ + balanceString(balance.Currency), + balanceDecimalString(balance.Amount), + balanceDecimalString(balance.Available), + balanceDecimalString(balance.AvailableForWithdrawal), + }) + } + return output.Write(w, output.TableData{ + Headers: []string{"CURRENCY", "AMOUNT", "AVAILABLE", "AVAILABLE FOR WITHDRAWAL"}, + Rows: rows, + }, format) +} + +func balanceString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func balanceDecimalString(value *sdktypes.DecimalNumber) string { + if value == nil { + return "" + } + return value.String() +} diff --git a/packages/gemini-markets-cli/internal/cli/private_account_test.go b/packages/gemini-markets-cli/internal/cli/private_account_test.go new file mode 100644 index 00000000..445f3750 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_account_test.go @@ -0,0 +1,76 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/gemini/developer-platform/packages/sdk-go/generated/account" + sdktypes "github.com/gemini/developer-platform/packages/sdk-go/types" +) + +type privateAccountFake struct { + request *account.GetAvailableBalancesJSONBody + value []account.Balance +} + +func (f *privateAccountFake) GetBalances(_ context.Context, request *account.GetAvailableBalancesJSONBody) ([]account.Balance, error) { + f.request = request + return f.value, nil +} + +type privateNopCloser struct{ closed bool } + +func (c *privateNopCloser) Close() error { + c.closed = true + return nil +} + +func TestAccountBalancesMapsRequestAndJSONOutput(t *testing.T) { + var out bytes.Buffer + fake := &privateAccountFake{} + closed := &privateNopCloser{} + root := newTestRootCommand(&out, &out) + root.AddCommand(NewAccountCommandWithFactory(func(_ context.Context, options GlobalOptions) (AccountBalancesClient, io.Closer, error) { + if options.Environment != "sandbox" || options.Profile != "trader" { + t.Fatalf("options = %#v", options) + } + return fake, closed, nil + })) + root.SetArgs([]string{"--environment", "sandbox", "--profile", "trader", "--output", "json", "account", "balances", "--account", "subaccount", "--show-pending-balances"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.request == nil || fake.request.Account != "subaccount" || fake.request.ShowPendingBalances == nil || !*fake.request.ShowPendingBalances { + t.Fatalf("request = %#v", fake.request) + } + if !closed.closed { + t.Fatal("factory owner was not closed") + } + var got []account.Balance + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output JSON = %q: %v", out.String(), err) + } +} + +func TestAccountBalancesTableOutput(t *testing.T) { + var out bytes.Buffer + currency := "USD" + amount := sdktypes.NewDecimalNumber(125, 2) + available := sdktypes.NewDecimalNumber(100, 2) + fake := &privateAccountFake{value: []account.Balance{{Currency: ¤cy, Amount: &amount, Available: &available}}} + root := newTestRootCommand(&out, &out) + root.AddCommand(NewAccountCommandWithFactory(func(context.Context, GlobalOptions) (AccountBalancesClient, io.Closer, error) { + return fake, nil, nil + })) + root.SetArgs([]string{"account", "balances"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(out.String(), "CURRENCY") || !strings.Contains(out.String(), "USD") || !strings.Contains(out.String(), "1.25") { + t.Fatalf("table output = %q", out.String()) + } +} diff --git a/packages/gemini-markets-cli/internal/cli/private_orders.go b/packages/gemini-markets-cli/internal/cli/private_orders.go new file mode 100644 index 00000000..3b8dc133 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_orders.go @@ -0,0 +1,960 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + "github.com/gemini/developer-platform/packages/sdk-go/generated/predictions" + "github.com/gemini/developer-platform/packages/sdk-go/generated/trading" + sdkservices "github.com/gemini/developer-platform/packages/sdk-go/services" + sdktypes "github.com/gemini/developer-platform/packages/sdk-go/types" + "github.com/spf13/cobra" +) + +// SpotOrdersClient is the narrow part of the SDK TradingService used by the +// spot order commands. +type SpotOrdersClient interface { + NewOrder(context.Context, *trading.NewOrderRequest) (*trading.LimitOrderResponse, error) + GetActiveOrders(context.Context, *trading.ListActiveOrdersJSONBody) ([]trading.LimitOrderResponse, error) + GetOrderStatus(context.Context, *trading.OrderStatusRequest) (*trading.LimitOrderResponse, error) + CancelOrder(context.Context, *trading.CancelOrderRequest) (*trading.CancelOrderResponse, error) +} + +// PredictionOrdersClient is the narrow part of the SDK PredictionsService +// used by the prediction order commands. +type PredictionOrdersClient interface { + NewOrder(context.Context, *predictions.OrderRequest) (*predictions.OrderResponse, error) + GetActiveOrders(context.Context, *predictions.GetActiveOrdersJSONRequestBody) (*predictions.OrdersResponse, error) + GetOrderHistory(context.Context, *predictions.GetOrderHistoryJSONRequestBody) (*predictions.OrdersResponse, error) + CancelOrder(context.Context, *predictions.CancelOrderJSONRequestBody) (*sdkservices.PredictionOrderOperationResponse, error) +} + +// SpotOrdersFactory creates the authenticated spot trading service and its +// owner. The owner is closed after each command invocation. +type SpotOrdersFactory func(context.Context, GlobalOptions) (SpotOrdersClient, io.Closer, error) + +// PredictionOrdersFactory creates the authenticated prediction trading +// service and its owner. The owner is closed after each command invocation. +type PredictionOrdersFactory func(context.Context, GlobalOptions) (PredictionOrdersClient, io.Closer, error) + +func defaultSpotOrdersFactory(ctx context.Context, options GlobalOptions) (SpotOrdersClient, io.Closer, error) { + value, err := newPrivateSession(ctx, options) + if err != nil { + return nil, nil, err + } + return value.Client.Trading, closeSession(value), nil +} + +func defaultPredictionOrdersFactory(ctx context.Context, options GlobalOptions) (PredictionOrdersClient, io.Closer, error) { + value, err := newPrivateSession(ctx, options) + if err != nil { + return nil, nil, err + } + return value.Client.Predictions, closeSession(value), nil +} + +// NewOrdersCommand creates the authenticated spot and prediction order +// command groups. +// +// Root command wiring intentionally remains in root.go's owner: callers should +// add the returned command with root.AddCommand(NewOrdersCommand()). +func NewOrdersCommand() *cobra.Command { + return NewOrdersCommandWithFactories(defaultSpotOrdersFactory, defaultPredictionOrdersFactory) +} + +// NewOrdersCommandWithFactories creates orders with injected SDK consumers. +// It is primarily useful for focused command tests. +func NewOrdersCommandWithFactories(spotFactory SpotOrdersFactory, predictionFactory PredictionOrdersFactory) *cobra.Command { + if spotFactory == nil { + spotFactory = defaultSpotOrdersFactory + } + if predictionFactory == nil { + predictionFactory = defaultPredictionOrdersFactory + } + + command := &cobra.Command{ + Use: "orders", + Aliases: []string{"order"}, + Short: "Authenticated order operations", + Args: cobra.NoArgs, + } + command.AddCommand(newSpotOrdersCommand(spotFactory), newPredictionOrdersCommand(predictionFactory)) + return command +} + +// NewPrivateOrdersCommand is an explicit-name alias for NewOrdersCommand. +func NewPrivateOrdersCommand() *cobra.Command { return NewOrdersCommand() } + +// NewPrivateOrdersCommandWithFactories is the explicit-name form for callers +// that need to inject SDK consumers into the private order tree. +func NewPrivateOrdersCommandWithFactories(spotFactory SpotOrdersFactory, predictionFactory PredictionOrdersFactory) *cobra.Command { + return NewOrdersCommandWithFactories(spotFactory, predictionFactory) +} + +func newSpotOrdersCommand(factory SpotOrdersFactory) *cobra.Command { + command := &cobra.Command{Use: "spot", Short: "Manage spot exchange orders", Args: cobra.NoArgs} + command.AddCommand( + newSpotPlaceCommand(factory), + newSpotListCommand(factory), + newSpotGetCommand(factory), + newSpotCancelCommand(factory), + ) + return command +} + +func newSpotPlaceCommand(factory SpotOrdersFactory) *cobra.Command { + var ( + symbol string + side string + amount string + price string + orderType string + option string + stopPrice string + clientOrderID string + accountName string + dryRun bool + ) + command := &cobra.Command{ + Use: "place", + Short: "Place a spot order", + Example: " gemini-markets orders spot place --symbol BTCUSD --side buy --amount 0.01 --price 60000 --dry-run\n" + + " gemini-markets --environment sandbox orders spot place --symbol BTCUSD --side sell --amount 0.01 --price 70000", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + request, err := buildSpotOrderRequest(symbol, side, amount, price, orderType, option, stopPrice, clientOrderID, accountName) + if err != nil { + return err + } + format := Options(cmd).Format + if dryRun { + return writeSpotOrderRequest(cmd.OutOrStdout(), request, format) + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create spot orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("spot orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.NewOrder(cmd.Context(), request) + if err != nil { + return fmt.Errorf("place spot order: %w", err) + } + return writeSpotOrderResponse(cmd.OutOrStdout(), response, format) + }, + } + command.Flags().StringVar(&symbol, "symbol", "", "trading symbol, for example BTCUSD") + command.Flags().StringVar(&side, "side", "", "order side (buy or sell)") + command.Flags().StringVar(&amount, "amount", "", "base amount") + command.Flags().StringVar(&price, "price", "", "price per unit") + command.Flags().StringVar(&orderType, "type", string(trading.NewOrderRequestTypeExchangeLimit), "order type (exchange limit or exchange stop limit; use --option immediate-or-cancel for IOC)") + command.Flags().StringVar(&option, "option", "", "execution option (maker-or-cancel, immediate-or-cancel, or fill-or-kill)") + command.Flags().StringVar(&stopPrice, "stop-price", "", "stop trigger price for stop-limit orders") + command.Flags().StringVar(&clientOrderID, "client-order-id", "", "client-specified order ID") + command.Flags().StringVarP(&accountName, "account", "a", "primary", "account or subaccount name") + command.Flags().BoolVar(&dryRun, "dry-run", false, "print the generated request without submitting it") + return command +} + +func buildSpotOrderRequest(symbol, side, amount, price, orderType, option, stopPrice, clientOrderID, accountName string) (*trading.NewOrderRequest, error) { + symbol = strings.TrimSpace(symbol) + side = strings.ToLower(strings.TrimSpace(side)) + amount = strings.TrimSpace(amount) + price = strings.TrimSpace(price) + orderType = strings.ToLower(strings.TrimSpace(orderType)) + option = strings.ToLower(strings.TrimSpace(option)) + stopPrice = strings.TrimSpace(stopPrice) + clientOrderID = strings.TrimSpace(clientOrderID) + accountName = strings.TrimSpace(accountName) + if symbol == "" { + return nil, fmt.Errorf("symbol is required") + } + if !trading.NewOrderRequestSide(side).Valid() { + return nil, fmt.Errorf("invalid side %q (want buy or sell)", side) + } + if amount == "" { + return nil, fmt.Errorf("amount is required") + } + if err := validatePositiveDecimal(amount, "amount"); err != nil { + return nil, err + } + if price == "" { + return nil, fmt.Errorf("price is required") + } + limit, err := positiveDecimal(price, "price") + if err != nil { + return nil, err + } + orderTypeValue := trading.NewOrderRequestType(orderType) + if !orderTypeValue.Valid() || (orderTypeValue != trading.NewOrderRequestTypeExchangeLimit && orderTypeValue != trading.NewOrderRequestTypeExchangeStopLimit) { + if orderTypeValue == trading.NewOrderRequestTypeExchangeMarket { + return nil, fmt.Errorf("exchange market orders are unsupported; use exchange limit with --option immediate-or-cancel") + } + return nil, fmt.Errorf("invalid order type %q", orderType) + } + if accountName == "" { + return nil, fmt.Errorf("account is required") + } + if stopPrice != "" && orderTypeValue != trading.NewOrderRequestTypeExchangeStopLimit { + return nil, fmt.Errorf("stop-price requires exchange stop limit order type") + } + if orderTypeValue == trading.NewOrderRequestTypeExchangeStopLimit && stopPrice == "" { + return nil, fmt.Errorf("stop-price is required for exchange stop limit orders") + } + if stopPrice != "" { + stop, err := positiveDecimal(stopPrice, "stop-price") + if err != nil { + return nil, err + } + if option != "" { + return nil, fmt.Errorf("stop-limit orders cannot use execution options") + } + switch trading.NewOrderRequestSide(side) { + case trading.NewOrderRequestSideBuy: + if stop.Cmp(limit) >= 0 { + return nil, fmt.Errorf("buy stop-price must be less than price") + } + case trading.NewOrderRequestSideSell: + if stop.Cmp(limit) <= 0 { + return nil, fmt.Errorf("sell stop-price must be greater than price") + } + } + } + request := &trading.NewOrderRequest{ + Account: stringPointer(accountName), + Amount: amount, + Price: price, + Request: "/v1/order/new", + Side: trading.NewOrderRequestSide(side), + Symbol: symbol, + Type: orderTypeValue, + } + if stopPrice != "" { + request.StopPrice = &stopPrice + } + if clientOrderID != "" { + request.ClientOrderId = &clientOrderID + } + if option != "" { + value := trading.NewOrderRequestOptions(option) + if !value.Valid() { + return nil, fmt.Errorf("invalid option %q", option) + } + request.Options = &[]trading.NewOrderRequestOptions{value} + } + return request, nil +} + +func newSpotListCommand(factory SpotOrdersFactory) *cobra.Command { + var accountName string + command := &cobra.Command{ + Use: "list", + Short: "List active spot orders", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + accountName = strings.TrimSpace(accountName) + if accountName == "" { + return fmt.Errorf("account is required") + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create spot orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("spot orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + orders, err := client.GetActiveOrders(cmd.Context(), &trading.ListActiveOrdersJSONBody{Account: stringPointer(accountName)}) + if err != nil { + return fmt.Errorf("list spot orders: %w", err) + } + return writeSpotOrders(cmd.OutOrStdout(), orders, Options(cmd).Format) + }, + } + command.Flags().StringVarP(&accountName, "account", "a", "primary", "account or subaccount name") + return command +} + +func newSpotGetCommand(factory SpotOrdersFactory) *cobra.Command { + var ( + accountName string + orderID string + clientOrderID string + includeTrades bool + ) + command := &cobra.Command{ + Use: "get", + Short: "Get a spot order status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + id, err := optionalUint64(orderID, "order-id") + if err != nil { + return err + } + clientOrderID = strings.TrimSpace(clientOrderID) + if id == 0 && clientOrderID == "" { + return fmt.Errorf("one of order-id or client-order-id is required") + } + if id != 0 && clientOrderID != "" { + return fmt.Errorf("order-id and client-order-id cannot be combined") + } + accountName = strings.TrimSpace(accountName) + if accountName == "" { + return fmt.Errorf("account is required") + } + request := &trading.OrderStatusRequest{Account: stringPointer(accountName), ClientOrderId: optionalString(clientOrderID), IncludeTrades: boolPointerIfSet(includeTrades, cmd.Flags().Changed("include-trades")), OrderId: id, Request: "/v1/order/status"} + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create spot orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("spot orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.GetOrderStatus(cmd.Context(), request) + if err != nil { + return fmt.Errorf("get spot order: %w", err) + } + return writeSpotOrderResponse(cmd.OutOrStdout(), response, Options(cmd).Format) + }, + } + command.Flags().StringVarP(&accountName, "account", "a", "primary", "account or subaccount name") + command.Flags().StringVar(&orderID, "order-id", "", "numeric order ID") + command.Flags().StringVar(&clientOrderID, "client-order-id", "", "client-specified order ID") + command.Flags().BoolVar(&includeTrades, "include-trades", false, "include individual fills") + return command +} + +func newSpotCancelCommand(factory SpotOrdersFactory) *cobra.Command { + var ( + accountName string + orderID string + ) + command := &cobra.Command{ + Use: "cancel", + Short: "Cancel a spot order", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + id, err := requiredUint64(orderID, "order-id") + if err != nil { + return err + } + accountName = strings.TrimSpace(accountName) + if accountName == "" { + return fmt.Errorf("account is required") + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create spot orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("spot orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.CancelOrder(cmd.Context(), &trading.CancelOrderRequest{Account: stringPointer(accountName), OrderId: id, Request: "/v1/order/cancel"}) + if err != nil { + return fmt.Errorf("cancel spot order: %w", err) + } + return writeSpotOrderResponse(cmd.OutOrStdout(), response, Options(cmd).Format) + }, + } + command.Flags().StringVarP(&accountName, "account", "a", "primary", "account or subaccount name") + command.Flags().StringVar(&orderID, "order-id", "", "numeric order ID") + return command +} + +func newPredictionOrdersCommand(factory PredictionOrdersFactory) *cobra.Command { + command := &cobra.Command{Use: "prediction", Aliases: []string{"predictions"}, Short: "Manage prediction-market orders", Args: cobra.NoArgs} + command.AddCommand( + newPredictionPlaceCommand(factory), + newPrivatePredictionListCommand(factory), + newPredictionCancelCommand(factory), + ) + return command +} + +func newPredictionPlaceCommand(factory PredictionOrdersFactory) *cobra.Command { + var ( + symbol string + side string + outcome string + quantity string + price string + orderType string + stopPrice string + timeInForce string + makerOrCancel bool + dryRun bool + ) + command := &cobra.Command{ + Use: "place", + Short: "Place a prediction-market order", + Example: " gemini-markets orders prediction place --symbol GEMI-FEDJAN26-DN25 --side buy --outcome yes --quantity 10 --price 0.65 --dry-run\n" + + " gemini-markets --environment sandbox orders prediction place --symbol GEMI-FEDJAN26-DN25 --side buy --outcome yes --quantity 10 --price 0.65", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + request, err := buildPredictionOrderRequest(symbol, side, outcome, quantity, price, orderType, stopPrice, timeInForce, makerOrCancel) + if err != nil { + return err + } + format := Options(cmd).Format + if dryRun { + return writePredictionOrderRequest(cmd.OutOrStdout(), request, format) + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create prediction orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("prediction orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.NewOrder(cmd.Context(), request) + if err != nil { + return fmt.Errorf("place prediction order: %w", err) + } + return writePredictionOrderResponse(cmd.OutOrStdout(), response, format) + }, + } + command.Flags().StringVar(&symbol, "symbol", "", "prediction contract symbol") + command.Flags().StringVar(&side, "side", "", "order side (buy or sell)") + command.Flags().StringVar(&outcome, "outcome", "", "contract outcome (yes or no)") + command.Flags().StringVar(&quantity, "quantity", "", "number of contracts") + command.Flags().StringVar(&price, "price", "", "limit price") + command.Flags().StringVar(&orderType, "type", string(predictions.OrderTypeLimit), "order type (limit or stop-limit)") + command.Flags().StringVar(&stopPrice, "stop-price", "", "stop trigger price for stop-limit orders") + command.Flags().StringVar(&timeInForce, "time-in-force", "", "time-in-force (good-til-cancel, immediate-or-cancel, or fill-or-kill)") + command.Flags().BoolVar(&makerOrCancel, "maker-or-cancel", false, "require maker-only execution") + command.Flags().BoolVar(&dryRun, "dry-run", false, "print the generated request without submitting it") + return command +} + +func buildPredictionOrderRequest(symbol, side, outcome, quantity, price, orderType, stopPrice, timeInForce string, makerOrCancel bool) (*predictions.OrderRequest, error) { + symbol = strings.TrimSpace(symbol) + side = strings.ToLower(strings.TrimSpace(side)) + outcome = strings.ToLower(strings.TrimSpace(outcome)) + quantity = strings.TrimSpace(quantity) + price = strings.TrimSpace(price) + orderType = strings.ToLower(strings.TrimSpace(orderType)) + stopPrice = strings.TrimSpace(stopPrice) + timeInForce = strings.ToLower(strings.TrimSpace(timeInForce)) + if symbol == "" { + return nil, fmt.Errorf("symbol is required") + } + if !predictions.OrderSide(side).Valid() { + return nil, fmt.Errorf("invalid side %q (want buy or sell)", side) + } + if !predictions.Outcome(outcome).Valid() { + return nil, fmt.Errorf("invalid outcome %q (want yes or no)", outcome) + } + if quantity == "" { + return nil, fmt.Errorf("quantity is required") + } + if err := validatePositiveDecimal(quantity, "quantity"); err != nil { + return nil, err + } + if price == "" { + return nil, fmt.Errorf("price is required") + } + limit, err := predictionPrice(price, "price") + if err != nil { + return nil, err + } + orderTypeValue := predictions.OrderType(orderType) + if !orderTypeValue.Valid() { + return nil, fmt.Errorf("invalid order type %q (want limit or stop-limit)", orderType) + } + if stopPrice != "" && orderTypeValue != predictions.OrderTypeStopLimit { + return nil, fmt.Errorf("stop-price requires stop-limit order type") + } + if orderTypeValue == predictions.OrderTypeStopLimit && stopPrice == "" { + return nil, fmt.Errorf("stop-price is required for stop-limit orders") + } + if stopPrice != "" { + stop, err := predictionPrice(stopPrice, "stop-price") + if err != nil { + return nil, err + } + switch predictions.OrderSide(side) { + case predictions.OrderSideBuy: + if stop.Cmp(limit) > 0 { + return nil, fmt.Errorf("buy stop-price must be less than or equal to price") + } + case predictions.OrderSideSell: + if stop.Cmp(limit) < 0 { + return nil, fmt.Errorf("sell stop-price must be greater than or equal to price") + } + } + } + request := &predictions.OrderRequest{ + OrderType: orderTypeValue, + Outcome: predictions.Outcome(outcome), + Price: price, + Quantity: quantity, + Side: predictions.OrderSide(side), + Symbol: symbol, + } + if stopPrice != "" { + request.StopPrice = &stopPrice + } + if timeInForce != "" { + value := predictions.TimeInForce(timeInForce) + if !value.Valid() { + return nil, fmt.Errorf("invalid time-in-force %q", timeInForce) + } + request.TimeInForce = &value + } + if makerOrCancel { + request.MakerOrCancel = &makerOrCancel + } + return request, nil +} + +func newPrivatePredictionListCommand(factory PredictionOrdersFactory) *cobra.Command { + var ( + symbol string + limit int + offset int + history bool + status string + from int64 + to int64 + ) + command := &cobra.Command{ + Use: "list", + Short: "List active prediction orders or order history", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + symbol = strings.TrimSpace(symbol) + format := Options(cmd).Format + for _, flag := range []string{"status", "from", "to"} { + if !history && cmd.Flags().Changed(flag) { + return fmt.Errorf("--%s requires --history", flag) + } + } + if cmd.Flags().Changed("offset") && (cmd.Flags().Changed("from") || cmd.Flags().Changed("to")) { + return fmt.Errorf("--offset cannot be combined with --from or --to") + } + if !history { + request := &predictions.GetActiveOrdersJSONRequestBody{Symbol: optionalString(symbol)} + if cmd.Flags().Changed("limit") { + if limit < 1 { + return fmt.Errorf("limit must be positive") + } + request.Limit = &limit + } + if cmd.Flags().Changed("offset") { + if offset < 0 { + return fmt.Errorf("offset cannot be negative") + } + request.Offset = &offset + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create prediction orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("prediction orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.GetActiveOrders(cmd.Context(), request) + if err != nil { + return fmt.Errorf("list prediction orders: %w", err) + } + return writePredictionOrders(cmd.OutOrStdout(), response, format) + } + request := &predictions.GetOrderHistoryJSONRequestBody{Symbol: optionalString(symbol)} + if cmd.Flags().Changed("limit") { + if limit < 1 { + return fmt.Errorf("limit must be positive") + } + request.Limit = &limit + } + if cmd.Flags().Changed("offset") { + if offset < 0 { + return fmt.Errorf("offset cannot be negative") + } + request.Offset = &offset + } + if cmd.Flags().Changed("from") { + if from < 0 { + return fmt.Errorf("from cannot be negative") + } + request.From = &from + } + if cmd.Flags().Changed("to") { + if to < 0 { + return fmt.Errorf("to cannot be negative") + } + request.To = &to + } + if cmd.Flags().Changed("status") { + status = strings.ToLower(strings.TrimSpace(status)) + value := predictions.GetOrderHistoryJSONBodyStatus(status) + if !value.Valid() { + return fmt.Errorf("invalid status %q (want filled or cancelled)", status) + } + request.Status = &value + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create prediction orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("prediction orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.GetOrderHistory(cmd.Context(), request) + if err != nil { + return fmt.Errorf("list prediction order history: %w", err) + } + return writePredictionOrders(cmd.OutOrStdout(), response, format) + }, + } + command.Flags().StringVar(&symbol, "symbol", "", "filter by contract symbol") + command.Flags().IntVar(&limit, "limit", 0, "maximum number of orders") + command.Flags().IntVar(&offset, "offset", 0, "number of orders to skip") + command.Flags().BoolVar(&history, "history", false, "list filled and cancelled orders") + command.Flags().StringVar(&status, "status", "", "history status (filled or cancelled)") + command.Flags().Int64Var(&from, "from", 0, "history start time in Unix milliseconds") + command.Flags().Int64Var(&to, "to", 0, "history end time in Unix milliseconds") + return command +} + +func newPredictionCancelCommand(factory PredictionOrdersFactory) *cobra.Command { + var orderID string + command := &cobra.Command{ + Use: "cancel", + Short: "Cancel a prediction-market order", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + id, err := requiredInt64(orderID, "order-id") + if err != nil { + return err + } + client, closer, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create prediction orders client: %w", err) + } + if client == nil { + if closer != nil { + _ = closer.Close() + } + return fmt.Errorf("prediction orders service is unavailable") + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + response, err := client.CancelOrder(cmd.Context(), &predictions.CancelOrderJSONRequestBody{OrderId: id}) + if err != nil { + return fmt.Errorf("cancel prediction order: %w", err) + } + return writePredictionOrderResponse(cmd.OutOrStdout(), response, Options(cmd).Format) + }, + } + command.Flags().StringVar(&orderID, "order-id", "", "numeric prediction order ID") + return command +} + +func optionalUint64(value, name string) (uint64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return parsed, nil +} + +func requiredUint64(value, name string) (uint64, error) { + parsed, err := optionalUint64(value, name) + if err != nil { + return 0, err + } + if parsed == 0 { + return 0, fmt.Errorf("%s is required", name) + } + return parsed, nil +} + +func requiredInt64(value, name string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("%s is required", name) + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return parsed, nil +} + +func validatePositiveDecimal(value, name string) error { + _, err := positiveDecimal(value, name) + return err +} + +func positiveDecimal(value, name string) (sdktypes.Decimal, error) { + if !plainUnsignedDecimal(value) { + return sdktypes.Decimal{}, fmt.Errorf("%s must be a positive decimal", name) + } + decimal, err := sdktypes.ParseDecimal(value) + if err != nil || !decimal.IsPositive() { + return sdktypes.Decimal{}, fmt.Errorf("%s must be a positive decimal", name) + } + return decimal, nil +} + +func predictionPrice(value, name string) (sdktypes.Decimal, error) { + decimal, err := positiveDecimal(value, name) + if err != nil || decimal.Cmp(sdktypes.MustParseDecimal("1")) > 0 { + return sdktypes.Decimal{}, fmt.Errorf("%s must be greater than 0 and no greater than 1", name) + } + return decimal, nil +} + +func plainUnsignedDecimal(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + hasDigit, hasDot := false, false + for i := 0; i < len(value); i++ { + switch value[i] { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + hasDigit = true + case '.': + if hasDot { + return false + } + hasDot = true + default: + return false + } + } + return hasDigit +} + +func stringPointer(value string) *string { return &value } + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func boolPointerIfSet(value, set bool) *bool { + if !set { + return nil + } + return &value +} + +func writeSpotOrderRequest(w io.Writer, value *trading.NewOrderRequest, format output.Format) error { + if format == output.JSON { + return output.Write(w, value, format) + } + return output.Write(w, output.TableData{Headers: []string{"FIELD", "VALUE"}, Rows: [][]string{ + {"account", privateStringValue(value.Account)}, {"symbol", value.Symbol}, {"side", string(value.Side)}, + {"type", string(value.Type)}, {"amount", value.Amount}, {"price", value.Price}, + {"option", spotOrderOptionsString(value.Options)}, + {"stop-price", privateStringValue(value.StopPrice)}, {"client-order-id", privateStringValue(value.ClientOrderId)}, + }}, format) +} + +func writePredictionOrderRequest(w io.Writer, value *predictions.OrderRequest, format output.Format) error { + if format == output.JSON { + return output.Write(w, value, format) + } + return output.Write(w, output.TableData{Headers: []string{"FIELD", "VALUE"}, Rows: [][]string{ + {"symbol", value.Symbol}, {"side", string(value.Side)}, {"outcome", string(value.Outcome)}, + {"order-type", string(value.OrderType)}, {"quantity", value.Quantity}, {"price", value.Price}, + {"stop-price", privateStringValue(value.StopPrice)}, {"time-in-force", predictionTimeInForceString(value.TimeInForce)}, + {"maker-or-cancel", boolValue(value.MakerOrCancel)}, + }}, format) +} + +func spotOrderOptionsString(value *[]trading.NewOrderRequestOptions) string { + if value == nil { + return "" + } + options := make([]string, 0, len(*value)) + for _, option := range *value { + options = append(options, string(option)) + } + return strings.Join(options, ",") +} + +func writeSpotOrderResponse(w io.Writer, value any, format output.Format) error { + if format == output.JSON { + return output.Write(w, value, format) + } + return output.Write(w, spotOrderTable(value), format) +} + +func writeSpotOrders(w io.Writer, values []trading.LimitOrderResponse, format output.Format) error { + if format == output.JSON { + return output.Write(w, values, format) + } + rows := make([][]string, 0, len(values)) + for _, value := range values { + rows = append(rows, []string{orderStringValue(value.OrderId), orderStringValue(value.Symbol), orderStringValue(value.Side), orderStringValue(value.Price), boolValue(value.IsLive)}) + } + return output.Write(w, output.TableData{Headers: []string{"ORDER ID", "SYMBOL", "SIDE", "PRICE", "LIVE"}, Rows: rows}, format) +} + +func spotOrderTable(value any) output.TableData { + var orderID, symbol, side, price, live string + switch order := value.(type) { + case *trading.LimitOrderResponse: + if order == nil { + return output.TableData{Headers: []string{"FIELD", "VALUE"}} + } + orderID, symbol, side, price, live = orderStringValue(order.OrderId), orderStringValue(order.Symbol), orderStringValue(order.Side), orderStringValue(order.Price), boolValue(order.IsLive) + case *trading.CancelOrderResponse: + if order == nil { + return output.TableData{Headers: []string{"FIELD", "VALUE"}} + } + orderID, symbol, side, price, live = orderStringValue(order.OrderId), orderStringValue(order.Symbol), orderStringValue(order.Side), orderStringValue(order.Price), boolValue(order.IsLive) + default: + return output.TableData{Headers: []string{"FIELD", "VALUE"}, Rows: [][]string{{"response", fmt.Sprintf("%v", value)}}} + } + return output.TableData{Headers: []string{"ORDER ID", "SYMBOL", "SIDE", "PRICE", "LIVE"}, Rows: [][]string{{orderID, symbol, side, price, live}}} +} + +func writePredictionOrderResponse(w io.Writer, value any, format output.Format) error { + if format == output.JSON { + return output.Write(w, value, format) + } + return output.Write(w, predictionOrderTable(value), format) +} + +func writePredictionOrders(w io.Writer, value *predictions.OrdersResponse, format output.Format) error { + if format == output.JSON { + return output.Write(w, value, format) + } + return output.Write(w, predictionOrdersTable(value), format) +} + +func predictionOrdersTable(value *predictions.OrdersResponse) output.TableData { + rows := [][]string{} + if value != nil && value.Orders != nil { + rows = make([][]string, 0, len(*value.Orders)) + for _, order := range *value.Orders { + rows = append(rows, []string{int64Value(order.OrderId), orderStringValue(order.Symbol), orderStringValue(order.Side), orderStringValue(order.Price), orderStringValue(order.Status)}) + } + } + return output.TableData{Headers: []string{"ORDER ID", "SYMBOL", "SIDE", "PRICE", "STATUS"}, Rows: rows} +} + +func predictionOrderTable(value any) output.TableData { + if order, ok := value.(*predictions.OrderResponse); ok { + if order == nil { + return output.TableData{Headers: []string{"FIELD", "VALUE"}} + } + return output.TableData{Headers: []string{"ORDER ID", "SYMBOL", "SIDE", "PRICE", "STATUS"}, Rows: [][]string{{int64Value(order.OrderId), orderStringValue(order.Symbol), orderStringValue(order.Side), orderStringValue(order.Price), orderStringValue(order.Status)}}} + } + if operation, ok := value.(*sdkservices.PredictionOrderOperationResponse); ok { + if operation == nil { + return output.TableData{Headers: []string{"FIELD", "VALUE"}} + } + return output.TableData{Headers: []string{"RESULT", "MESSAGE"}, Rows: [][]string{{operation.Result, operation.Message}}} + } + return output.TableData{Headers: []string{"FIELD", "VALUE"}, Rows: [][]string{{"response", fmt.Sprintf("%v", value)}}} +} + +func privateStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func orderStringValue(value any) string { + switch typed := value.(type) { + case *string: + return privateStringValue(typed) + case *trading.LimitOrderResponseSide: + if typed != nil { + return string(*typed) + } + case *trading.CancelOrderResponseSide: + if typed != nil { + return string(*typed) + } + case *predictions.OrderSide: + if typed != nil { + return string(*typed) + } + case *predictions.OrderStatus: + if typed != nil { + return string(*typed) + } + } + return "" +} + +func boolValue(value *bool) string { + if value == nil { + return "" + } + return strconv.FormatBool(*value) +} + +func int64Value(value *int64) string { + if value == nil { + return "" + } + return strconv.FormatInt(*value, 10) +} + +func predictionTimeInForceString(value *predictions.TimeInForce) string { + if value == nil { + return "" + } + return string(*value) +} diff --git a/packages/gemini-markets-cli/internal/cli/private_orders_test.go b/packages/gemini-markets-cli/internal/cli/private_orders_test.go new file mode 100644 index 00000000..c7ac6ce1 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_orders_test.go @@ -0,0 +1,323 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/gemini/developer-platform/packages/sdk-go/generated/predictions" + "github.com/gemini/developer-platform/packages/sdk-go/generated/trading" + sdkservices "github.com/gemini/developer-platform/packages/sdk-go/services" + "github.com/spf13/cobra" +) + +type privateSpotFake struct { + newRequest *trading.NewOrderRequest + activeRequest *trading.ListActiveOrdersJSONBody + statusRequest *trading.OrderStatusRequest + cancelRequest *trading.CancelOrderRequest +} + +func (f *privateSpotFake) NewOrder(_ context.Context, request *trading.NewOrderRequest) (*trading.LimitOrderResponse, error) { + f.newRequest = request + id := "123" + return &trading.LimitOrderResponse{OrderId: &id}, nil +} + +func (f *privateSpotFake) GetActiveOrders(_ context.Context, request *trading.ListActiveOrdersJSONBody) ([]trading.LimitOrderResponse, error) { + f.activeRequest = request + return nil, nil +} + +func (f *privateSpotFake) GetOrderStatus(_ context.Context, request *trading.OrderStatusRequest) (*trading.LimitOrderResponse, error) { + f.statusRequest = request + return &trading.LimitOrderResponse{}, nil +} + +func (f *privateSpotFake) CancelOrder(_ context.Context, request *trading.CancelOrderRequest) (*trading.CancelOrderResponse, error) { + f.cancelRequest = request + return &trading.CancelOrderResponse{}, nil +} + +type privatePredictionFake struct { + newRequest *predictions.OrderRequest + activeRequest *predictions.GetActiveOrdersJSONRequestBody + historyRequest *predictions.GetOrderHistoryJSONRequestBody + cancelRequest *predictions.CancelOrderJSONRequestBody +} + +func (f *privatePredictionFake) NewOrder(_ context.Context, request *predictions.OrderRequest) (*predictions.OrderResponse, error) { + f.newRequest = request + return &predictions.OrderResponse{}, nil +} + +func (f *privatePredictionFake) GetActiveOrders(_ context.Context, request *predictions.GetActiveOrdersJSONRequestBody) (*predictions.OrdersResponse, error) { + f.activeRequest = request + return &predictions.OrdersResponse{}, nil +} + +func (f *privatePredictionFake) GetOrderHistory(_ context.Context, request *predictions.GetOrderHistoryJSONRequestBody) (*predictions.OrdersResponse, error) { + f.historyRequest = request + return &predictions.OrdersResponse{}, nil +} + +func (f *privatePredictionFake) CancelOrder(_ context.Context, request *predictions.CancelOrderJSONRequestBody) (*sdkservices.PredictionOrderOperationResponse, error) { + f.cancelRequest = request + return &sdkservices.PredictionOrderOperationResponse{Result: "ok"}, nil +} + +func spotFactory(fake *privateSpotFake) SpotOrdersFactory { + return func(context.Context, GlobalOptions) (SpotOrdersClient, io.Closer, error) { return fake, nil, nil } +} + +func predictionFactory(fake *privatePredictionFake) PredictionOrdersFactory { + return func(context.Context, GlobalOptions) (PredictionOrdersClient, io.Closer, error) { return fake, nil, nil } +} + +type privateOrderCloser struct{ closed bool } + +func (c *privateOrderCloser) Close() error { + c.closed = true + return nil +} + +func TestCancelCommandsHandleNilClient(t *testing.T) { + tests := []struct { + name string + args []string + add func(*cobra.Command, *privateOrderCloser) + }{ + { + name: "spot", + args: []string{"orders", "spot", "cancel", "--order-id", "42"}, + add: func(root *cobra.Command, closer *privateOrderCloser) { + root.AddCommand(NewOrdersCommandWithFactories(func(context.Context, GlobalOptions) (SpotOrdersClient, io.Closer, error) { + return nil, closer, nil + }, nil)) + }, + }, + { + name: "prediction", + args: []string{"orders", "prediction", "cancel", "--order-id", "88"}, + add: func(root *cobra.Command, closer *privateOrderCloser) { + root.AddCommand(NewOrdersCommandWithFactories(nil, func(context.Context, GlobalOptions) (PredictionOrdersClient, io.Closer, error) { + return nil, closer, nil + })) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + closer := &privateOrderCloser{} + root := newTestRootCommand(&out, &out) + test.add(root, closer) + root.SetArgs(test.args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "service is unavailable") { + t.Fatalf("Execute() error = %v, want unavailable service error", err) + } + if !closer.closed { + t.Fatal("order factory closer was not closed") + } + }) + } +} + +func TestSpotPlaceDryRunDoesNotCreateClient(t *testing.T) { + var out bytes.Buffer + called := false + root := newTestRootCommand(&out, &out) + root.AddCommand(NewOrdersCommandWithFactories(func(context.Context, GlobalOptions) (SpotOrdersClient, io.Closer, error) { + called = true + return nil, nil, errors.New("must not be called") + }, nil)) + root.SetArgs([]string{"--output", "json", "orders", "spot", "place", "--symbol", "BTCUSD", "--side", "buy", "--amount", "0.1", "--price", "65000", "--client-order-id", "cli-1", "--dry-run"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if called { + t.Fatal("spot factory called during dry run") + } + var request trading.NewOrderRequest + if err := json.Unmarshal(out.Bytes(), &request); err != nil { + t.Fatalf("output JSON = %q: %v", out.String(), err) + } + if request.Symbol != "BTCUSD" || request.Side != trading.NewOrderRequestSideBuy || request.Amount != "0.1" || request.ClientOrderId == nil || *request.ClientOrderId != "cli-1" { + t.Fatalf("request = %#v", request) + } +} + +func TestOrderDryRunTablesShowExecutionOptions(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "spot execution option", + args: []string{"orders", "spot", "place", "--symbol", "BTCUSD", "--side", "buy", "--amount", "0.1", "--price", "65000", "--option", "immediate-or-cancel", "--dry-run"}, + want: []string{"option", "immediate-or-cancel"}, + }, + { + name: "prediction maker-only", + args: []string{"orders", "prediction", "place", "--symbol", "GEMI", "--side", "buy", "--outcome", "yes", "--quantity", "10", "--price", "0.40", "--maker-or-cancel", "--dry-run"}, + want: []string{"maker-or-cancel", "true"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + root := newTestRootCommand(&out, &out) + root.AddCommand(NewOrdersCommandWithFactories(nil, nil)) + root.SetArgs(test.args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + for _, want := range test.want { + if !strings.Contains(out.String(), want) { + t.Fatalf("output = %q, want %q", out.String(), want) + } + } + }) + } +} + +func TestSpotOrderCommandsMapSDKRequests(t *testing.T) { + tests := []struct { + name string + args []string + check func(*testing.T, *privateSpotFake) + }{ + {name: "list", args: []string{"orders", "spot", "list", "--account", "desk"}, check: func(t *testing.T, fake *privateSpotFake) { + if fake.activeRequest == nil || fake.activeRequest.Account == nil || *fake.activeRequest.Account != "desk" { + t.Fatalf("active request = %#v", fake.activeRequest) + } + }}, + {name: "cancel", args: []string{"orders", "spot", "cancel", "--order-id", "42"}, check: func(t *testing.T, fake *privateSpotFake) { + if fake.cancelRequest == nil || fake.cancelRequest.OrderId != 42 { + t.Fatalf("cancel request = %#v", fake.cancelRequest) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + fake := &privateSpotFake{} + root := newTestRootCommand(&out, &out) + root.AddCommand(NewOrdersCommandWithFactories(spotFactory(fake), nil)) + root.SetArgs(append([]string{"--output", "json"}, test.args...)) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + test.check(t, fake) + }) + } +} + +func TestPredictionPlaceDryRunMapsGeneratedRequest(t *testing.T) { + var out bytes.Buffer + root := newTestRootCommand(&out, &out) + root.AddCommand(NewOrdersCommandWithFactories(nil, func(context.Context, GlobalOptions) (PredictionOrdersClient, io.Closer, error) { + return nil, nil, errors.New("must not be called") + })) + root.SetArgs([]string{"--output", "json", "orders", "prediction", "place", "--symbol", "GEMI-FEDJAN26-UP", "--side", "sell", "--outcome", "no", "--quantity", "10", "--price", "0.40", "--time-in-force", "fill-or-kill", "--maker-or-cancel", "--dry-run"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var request predictions.OrderRequest + if err := json.Unmarshal(out.Bytes(), &request); err != nil { + t.Fatalf("output JSON = %q: %v", out.String(), err) + } + if request.Symbol == "" || request.Side != predictions.OrderSideSell || request.Outcome != predictions.No || request.Quantity != "10" || request.MakerOrCancel == nil || !*request.MakerOrCancel || request.TimeInForce == nil || *request.TimeInForce != predictions.TimeInForceFillOrKill { + t.Fatalf("request = %#v", request) + } +} + +func TestPredictionOrderCommandsMapListCancel(t *testing.T) { + fake := &privatePredictionFake{} + commands := [][]string{ + {"orders", "prediction", "list", "--history", "--symbol", "GEMI", "--status", "filled", "--limit", "5", "--offset", "2"}, + {"orders", "prediction", "cancel", "--order-id", "88"}, + } + for _, args := range commands { + var out bytes.Buffer + root := newTestRootCommand(&out, &out) + root.AddCommand(NewOrdersCommandWithFactories(nil, predictionFactory(fake))) + root.SetArgs(append([]string{"--output", "json"}, args...)) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(%v) error = %v", args, err) + } + } + if fake.historyRequest == nil || fake.historyRequest.Symbol == nil || *fake.historyRequest.Symbol != "GEMI" || fake.historyRequest.Status == nil || *fake.historyRequest.Status != predictions.GetOrderHistoryJSONBodyStatusFilled || fake.historyRequest.Limit == nil || *fake.historyRequest.Limit != 5 || fake.historyRequest.Offset == nil || *fake.historyRequest.Offset != 2 { + t.Fatalf("history request = %#v", fake.historyRequest) + } + if fake.cancelRequest == nil || fake.cancelRequest.OrderId != 88 { + t.Fatalf("cancel request = %#v", fake.cancelRequest) + } +} + +func TestPredictionListRejectsMisleadingHistoryFlags(t *testing.T) { + for _, flag := range []string{"status", "from", "to"} { + root := newTestRootCommand(io.Discard, io.Discard) + root.AddCommand(NewOrdersCommandWithFactories(nil, predictionFactory(&privatePredictionFake{}))) + root.SetArgs([]string{"orders", "prediction", "list", "--" + flag, map[string]string{"status": "filled", "from": "1", "to": "2"}[flag]}) + if err := root.Execute(); err == nil { + t.Fatalf("flag --%s was accepted without --history", flag) + } + } + root := newTestRootCommand(io.Discard, io.Discard) + root.AddCommand(NewOrdersCommandWithFactories(nil, predictionFactory(&privatePredictionFake{}))) + root.SetArgs([]string{"orders", "prediction", "list", "--history", "--offset", "1", "--from", "2"}) + if err := root.Execute(); err == nil { + t.Fatal("offset with time bounds was accepted") + } +} + +func TestPrivateRequestValidation(t *testing.T) { + if _, err := buildSpotOrderRequest("BTCUSD", "buy", "1", "100", "exchange limit", "", "99", "", "primary"); err == nil { + t.Fatal("expected stop-price validation error") + } + if _, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "1", "0.5", "limit", "0.4", "", false); err == nil { + t.Fatal("expected prediction stop-price validation error") + } + if _, err := buildSpotOrderRequest("BTCUSD", "buy", "0", "100", "exchange limit", "", "", "", "primary"); err == nil { + t.Fatal("expected positive amount validation error") + } + if _, err := buildSpotOrderRequest("BTCUSD", "buy", "1", "100", "exchange market", "", "", "", "primary"); err == nil { + t.Fatal("expected unsupported market order error") + } + if _, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "0", "0.5", "limit", "", "", false); err == nil { + t.Fatal("expected positive quantity validation error") + } + if _, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "1", "1.01", "limit", "", "", false); err == nil { + t.Fatal("expected prediction price upper-bound validation error") + } + if request, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "1", "0.0001", "limit", "", "", false); err != nil || request.Price != "0.0001" { + t.Fatalf("fine-grid prediction price rejected: request=%#v err=%v", request, err) + } + for _, value := range []string{"+0.5", "1e-1"} { + if _, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "1", value, "limit", "", "", false); err == nil { + t.Fatalf("non-canonical prediction price %q was accepted", value) + } + } + if _, err := buildSpotOrderRequest("BTCUSD", "buy", "1", "100", "exchange stop limit", "", "101", "", "primary"); err == nil { + t.Fatal("expected spot buy stop direction validation error") + } + if _, err := buildSpotOrderRequest("BTCUSD", "sell", "1", "100", "exchange stop limit", "", "99", "", "primary"); err == nil { + t.Fatal("expected spot sell stop direction validation error") + } + if _, err := buildPredictionOrderRequest("GEMI", "buy", "yes", "1", "0.50", "stop-limit", "0.51", "", false); err == nil { + t.Fatal("expected prediction buy stop direction validation error") + } + if _, err := buildPredictionOrderRequest("GEMI", "sell", "yes", "1", "0.50", "stop-limit", "0.49", "", false); err == nil { + t.Fatal("expected prediction sell stop direction validation error") + } + if _, err := requiredInt64("-1", "order-id"); err == nil { + t.Fatal("expected positive order ID validation error") + } +} diff --git a/packages/gemini-markets-cli/internal/cli/private_session.go b/packages/gemini-markets-cli/internal/cli/private_session.go new file mode 100644 index 00000000..2e82e57a --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_session.go @@ -0,0 +1,63 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/credentials" + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/session" + gemini "github.com/gemini/developer-platform/packages/sdk-go" +) + +// newPrivateSession loads the selected profile's environment credentials and +// creates the official SDK client. Private commands deliberately share this +// small construction path so authentication remains owned by the session and +// SDK packages. +func newPrivateSession(ctx context.Context, options GlobalOptions) (*session.Session, error) { + config, err := privateSessionConfig(ctx, options) + if err != nil { + return nil, err + } + return session.New(config) +} + +func privateSessionConfig(ctx context.Context, options GlobalOptions) (session.Config, error) { + keyring := credentials.NewDefaultKeyring() + profile := credentialProfile(options) + value, origin, err := credentials.LoadWithOrigin(ctx, credentials.LoadOptions{Profile: profile, Keyring: keyring}) + if err != nil { + return session.Config{}, fmt.Errorf("load credentials: %w", err) + } + if err := requirePrivateCredentials(value); err != nil { + return session.Config{}, err + } + if value.RefreshToken != "" && origin != credentials.OriginKeyring { + return session.Config{}, errors.New("environment refresh tokens cannot be rotated safely; run `gemini-markets auth login` to store refreshable OAuth credentials, or set only GEMINI_ACCESS_TOKEN") + } + config := session.Config{Environment: gemini.Environment(options.Environment), Credentials: value} + if origin == credentials.OriginKeyring { + config.CredentialStore = keyring + config.CredentialProfile = profile + } + return config, nil +} + +// requirePrivateCredentials keeps authenticated commands from constructing an +// unauthenticated SDK client and only discovering the missing credentials +// after a network request. Public commands intentionally do not use this +// check. +func requirePrivateCredentials(value credentials.Credentials) error { + if value.AccessToken != "" || value.RefreshToken != "" || value.APIKey != "" || value.APISecret != "" { + return nil + } + return errors.New("authenticated command requires credentials; run `gemini-markets auth login` or set GEMINI_API_KEY and GEMINI_API_SECRET") +} + +func closeSession(value *session.Session) io.Closer { + if value == nil { + return nil + } + return value +} diff --git a/packages/gemini-markets-cli/internal/cli/private_session_test.go b/packages/gemini-markets-cli/internal/cli/private_session_test.go new file mode 100644 index 00000000..b23c3a41 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/private_session_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "context" + "strings" + "testing" +) + +func TestPrivateSessionConfigRejectsEnvironmentRefreshToken(t *testing.T) { + clearCredentialEnvironment(t) + t.Setenv("GEMINI_REFRESH_TOKEN", "refresh-token") + t.Setenv("GEMINI_OAUTH_CLIENT_ID", "client-id") + + _, err := privateSessionConfig(context.Background(), GlobalOptions{Environment: "sandbox", Profile: "default"}) + if err == nil || !strings.Contains(err.Error(), "environment refresh tokens cannot be rotated safely") { + t.Fatalf("privateSessionConfig() error = %v, want unsafe rotation error", err) + } +} + +func TestPrivateSessionConfigAcceptsEnvironmentAccessTokenWithoutPersistence(t *testing.T) { + clearCredentialEnvironment(t) + t.Setenv("GEMINI_ACCESS_TOKEN", "access-token") + + config, err := privateSessionConfig(context.Background(), GlobalOptions{Environment: "sandbox", Profile: "default"}) + if err != nil { + t.Fatalf("privateSessionConfig() error = %v", err) + } + if config.Credentials.AccessToken != "access-token" || config.CredentialStore != nil || config.CredentialProfile != "" { + t.Fatal("privateSessionConfig() did not return a non-persistent environment bearer token") + } +} + +func clearCredentialEnvironment(t *testing.T) { + t.Helper() + for _, key := range []string{ + "GEMINI_API_KEY", + "GEMINI_API_SECRET", + "GEMINI_ACCESS_TOKEN", + "GEMINI_BEARER_TOKEN", + "GEMINI_REFRESH_TOKEN", + "GEMINI_OAUTH_CLIENT_ID", + "GEMINI_OAUTH_CLIENT_SECRET", + } { + t.Setenv(key, "") + } +} diff --git a/packages/gemini-markets-cli/internal/cli/public.go b/packages/gemini-markets-cli/internal/cli/public.go new file mode 100644 index 00000000..90c937e6 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/public.go @@ -0,0 +1,160 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/session" + gemini "github.com/gemini/developer-platform/packages/sdk-go" + "github.com/gemini/developer-platform/packages/sdk-go/generated/marketdata" + "github.com/gemini/developer-platform/packages/sdk-go/generated/predictions" + "github.com/spf13/cobra" +) + +// PublicMarketData is the part of the SDK market-data service used by the +// public CLI. Keeping this interface consumer-defined makes command tests +// independent of HTTP and leaves the SDK as the only protocol implementation. +type PublicMarketData interface { + GetSymbols(context.Context) ([]string, error) + GetTicker(context.Context, string) (*marketdata.Ticker, error) + GetOrderBook(context.Context, string, int, int) (*marketdata.OrderBook, error) + GetCandles(context.Context, string, string) (marketdata.CandleResponse, error) +} + +// PublicPredictions is the subset of the SDK predictions service used by this +// command group. Terms status and acceptance are authenticated operations even +// though they live alongside public prediction-market discovery commands. +type PublicPredictions interface { + GetEvents(context.Context, *predictions.ListEventsParams) (*predictions.EventsResponse, error) + GetEvent(context.Context, string) (*predictions.Event, error) + GetTerms(context.Context) (*predictions.PredictionMarketsTerms, error) + GetTermsStatus(context.Context) (*predictions.PredictionMarketsTermsStatus, error) + AcceptTerms(context.Context) (*predictions.AcceptPredictionMarketsTermsResponse, error) +} + +// PublicServices contains SDK services needed by public commands. Close is +// optional and lets a factory release transports when a command completes. +// The fields intentionally expose interfaces rather than wrapping SDK models. +type PublicServices struct { + MarketData PublicMarketData + Predictions PublicPredictions + Close func() error +} + +// PublicServiceFactory creates services for one command invocation. Options +// are read from the root command, so the environment/profile conventions stay +// shared with every other CLI command group. +type PublicServiceFactory func(context.Context, GlobalOptions) (PublicServices, error) + +// NewPublicCommand creates the public market and prediction-market command tree. +func NewPublicCommand(factories ...PublicServiceFactory) *cobra.Command { + publicFactory, authenticatedFactory := publicFactories(factories) + command := &cobra.Command{ + Use: "public", + Short: "Public market and prediction-market data", + Args: cobra.NoArgs, + } + command.AddCommand(newMarketsCommand(publicFactory), newPublicPredictionMarketsCommand(publicFactory, authenticatedFactory)) + return command +} + +// NewPublicCommandWithFactory is the explicit form of NewPublicCommand for +// applications and tests that need to provide their own session/service +// construction. +func NewPublicCommandWithFactory(factory PublicServiceFactory) *cobra.Command { + return NewPublicCommand(factory) +} + +// NewPublicCommandWithServices creates a complete public command tree backed +// by a fixed service set. It is useful for small embedders and unit tests. +func NewPublicCommandWithServices(services PublicServices) *cobra.Command { + return NewPublicCommand(func(context.Context, GlobalOptions) (PublicServices, error) { + return services, nil + }) +} + +// NewMarketsCommand creates the market-data command group. It accepts an +// optional factory so root registration can use the default session while +// tests inject a deterministic SDK-service fake. +func NewMarketsCommand(factories ...PublicServiceFactory) *cobra.Command { + return newMarketsCommand(publicFactory(factories)) +} + +// NewMarketsCommandWithFactory is the explicit factory-injection form of +// NewMarketsCommand. +func NewMarketsCommandWithFactory(factory PublicServiceFactory) *cobra.Command { + return NewMarketsCommand(factory) +} + +// NewPredictionMarketsCommand creates the prediction-market discovery group. +func NewPredictionMarketsCommand(factories ...PublicServiceFactory) *cobra.Command { + publicFactory, authenticatedFactory := publicFactories(factories) + return newPublicPredictionMarketsCommand(publicFactory, authenticatedFactory) +} + +// NewPredictionMarketsCommandWithFactory is the explicit factory-injection +// form of NewPredictionMarketsCommand. +func NewPredictionMarketsCommandWithFactory(factory PublicServiceFactory) *cobra.Command { + return NewPredictionMarketsCommand(factory) +} + +func publicFactory(factories []PublicServiceFactory) PublicServiceFactory { + if len(factories) > 0 && factories[0] != nil { + return factories[0] + } + return defaultPublicServiceFactory +} + +func publicFactories(factories []PublicServiceFactory) (PublicServiceFactory, PublicServiceFactory) { + if len(factories) > 0 && factories[0] != nil { + return factories[0], factories[0] + } + return defaultPublicServiceFactory, defaultAuthenticatedPublicServiceFactory +} + +func defaultPublicServiceFactory(ctx context.Context, options GlobalOptions) (PublicServices, error) { + current, err := session.New(session.Config{ + Environment: gemini.Environment(options.Environment), + }) + if err != nil { + return PublicServices{}, err + } + return PublicServices{ + MarketData: current.Client.MarketData, + Predictions: current.Client.Predictions, + Close: current.Close, + }, nil +} + +func defaultAuthenticatedPublicServiceFactory(ctx context.Context, options GlobalOptions) (PublicServices, error) { + current, err := newPrivateSession(ctx, options) + if err != nil { + return PublicServices{}, err + } + return PublicServices{ + MarketData: current.Client.MarketData, + Predictions: current.Client.Predictions, + Close: current.Close, + }, nil +} + +func withPublicServices(cmd *cobra.Command, factory PublicServiceFactory, run func(PublicServices) error) error { + if factory == nil { + return errors.New("public command service factory is nil") + } + services, err := factory(cmd.Context(), Options(cmd)) + if err != nil { + return fmt.Errorf("create public API session: %w", err) + } + if run != nil { + err = run(services) + } + if services.Close != nil { + closeErr := services.Close() + if err == nil { + err = closeErr + } + } + return err +} diff --git a/packages/gemini-markets-cli/internal/cli/public_markets.go b/packages/gemini-markets-cli/internal/cli/public_markets.go new file mode 100644 index 00000000..41b87b8d --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/public_markets.go @@ -0,0 +1,249 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + "github.com/gemini/developer-platform/packages/sdk-go/generated/marketdata" + "github.com/spf13/cobra" +) + +func newMarketsCommand(factory PublicServiceFactory) *cobra.Command { + command := &cobra.Command{ + Use: "markets", + Aliases: []string{"market"}, + Short: "Public spot and derivatives market data", + Args: cobra.NoArgs, + } + command.AddCommand( + newMarketSymbolsCommand(factory), + newMarketTickerCommand(factory), + newMarketBookCommand(factory), + newMarketCandlesCommand(factory), + ) + return command +} + +func newMarketSymbolsCommand(factory PublicServiceFactory) *cobra.Command { + return &cobra.Command{ + Use: "symbols", + Aliases: []string{"symbol"}, + Short: "List available market symbols", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.MarketData == nil { + return fmt.Errorf("market data service is unavailable") + } + values, err := services.MarketData.GetSymbols(cmd.Context()) + if err != nil { + return fmt.Errorf("list market symbols: %w", err) + } + return writePublicResult(cmd, values, tableForStrings("SYMBOL", values)) + }) + }, + } +} + +func newMarketTickerCommand(factory PublicServiceFactory) *cobra.Command { + return &cobra.Command{ + Use: "ticker SYMBOL", + Aliases: []string{"price"}, + Short: "Show the latest ticker for a symbol", + Args: oneNonEmptyArgument("symbol"), + RunE: func(cmd *cobra.Command, args []string) error { + symbol := strings.TrimSpace(args[0]) + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.MarketData == nil { + return fmt.Errorf("market data service is unavailable") + } + ticker, err := services.MarketData.GetTicker(cmd.Context(), symbol) + if err != nil { + return fmt.Errorf("get ticker for %s: %w", symbol, err) + } + return writePublicResult(cmd, ticker, tickerTable(symbol, ticker)) + }) + }, + } +} + +func newMarketBookCommand(factory PublicServiceFactory) *cobra.Command { + var limitBids, limitAsks int + command := &cobra.Command{ + Use: "book SYMBOL", + Aliases: []string{"order-book"}, + Short: "Show the current order-book snapshot", + Args: oneNonEmptyArgument("symbol"), + RunE: func(cmd *cobra.Command, args []string) error { + if limitBids < -1 || limitAsks < -1 { + return fmt.Errorf("book limits must be -1 (omit) or non-negative") + } + symbol := strings.TrimSpace(args[0]) + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.MarketData == nil { + return fmt.Errorf("market data service is unavailable") + } + book, err := services.MarketData.GetOrderBook(cmd.Context(), symbol, limitBids, limitAsks) + if err != nil { + return fmt.Errorf("get order book for %s: %w", symbol, err) + } + return writePublicResult(cmd, book, bookTable(book)) + }) + }, + } + command.Flags().IntVar(&limitBids, "limit-bids", -1, "maximum bid levels (-1 omits the parameter)") + command.Flags().IntVar(&limitAsks, "limit-asks", -1, "maximum ask levels (-1 omits the parameter)") + return command +} + +func newMarketCandlesCommand(factory PublicServiceFactory) *cobra.Command { + command := &cobra.Command{ + Use: "candles SYMBOL TIMEFRAME", + Aliases: []string{"candle"}, + Short: "Show OHLCV candles for a symbol", + Args: twoNonEmptyArguments("symbol", "timeframe"), + RunE: func(cmd *cobra.Command, args []string) error { + symbol, timeframe := strings.TrimSpace(args[0]), strings.TrimSpace(args[1]) + apiTimeframe, err := normalizeCandleTimeframe(timeframe) + if err != nil { + return err + } + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.MarketData == nil { + return fmt.Errorf("market data service is unavailable") + } + candles, err := services.MarketData.GetCandles(cmd.Context(), symbol, apiTimeframe) + if err != nil { + return fmt.Errorf("get candles for %s (%s): %w", symbol, timeframe, err) + } + return writePublicResult(cmd, candles, candlesTable(candles)) + }) + }, + } + return command +} + +func normalizeCandleTimeframe(timeframe string) (string, error) { + switch strings.ToLower(strings.TrimSpace(timeframe)) { + case "1m", "5m", "15m", "30m": + return strings.ToLower(strings.TrimSpace(timeframe)), nil + case "1h", "1hr": + return "1hr", nil + case "6h", "6hr": + return "6hr", nil + case "1d", "1day": + return "1day", nil + default: + return "", fmt.Errorf("invalid timeframe %q (want 1m, 5m, 15m, 30m, 1h, 6h, or 1d)", timeframe) + } +} + +func writePublicResult(cmd *cobra.Command, value any, table output.TableData) error { + format := Options(cmd).Format + if format == output.JSON { + return output.Write(cmd.OutOrStdout(), value, output.JSON) + } + return output.Write(cmd.OutOrStdout(), table, output.Table) +} + +func oneNonEmptyArgument(name string) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return err + } + if strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("%s is required", name) + } + return nil + } +} + +func twoNonEmptyArguments(first, second string) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(2)(cmd, args); err != nil { + return err + } + if strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("%s is required", first) + } + if strings.TrimSpace(args[1]) == "" { + return fmt.Errorf("%s is required", second) + } + return nil + } +} + +func tableForStrings(header string, values []string) output.TableData { + rows := make([][]string, 0, len(values)) + for _, value := range values { + rows = append(rows, []string{value}) + } + return output.TableData{Headers: []string{header}, Rows: rows} +} + +func tickerTable(symbol string, ticker *marketdata.Ticker) output.TableData { + headers := []string{"SYMBOL", "BID", "ASK", "LAST", "PRICE_VOLUME", "QUANTITY_VOLUME", "VOLUME_TIMESTAMP"} + if ticker == nil { + return output.TableData{Headers: headers, Rows: [][]string{{symbol, "", "", "", "", "", ""}}} + } + row := []string{symbol, publicStringValue(ticker.Bid), publicStringValue(ticker.Ask), publicStringValue(ticker.Last)} + if ticker.Volume != nil { + row = append(row, publicStringValue(ticker.Volume.PriceSymbol), publicStringValue(ticker.Volume.QuantitySymbol), timestampValue(ticker.Volume.Timestamp)) + } else { + row = append(row, "", "", "") + } + return output.TableData{Headers: headers, Rows: [][]string{row}} +} + +func bookTable(book *marketdata.OrderBook) output.TableData { + table := output.TableData{Headers: []string{"SIDE", "PRICE", "AMOUNT"}} + if book == nil { + return table + } + if book.Bids != nil { + for _, level := range *book.Bids { + table.Rows = append(table.Rows, []string{"BID", publicStringValue(level.Price), publicStringValue(level.Amount)}) + } + } + if book.Asks != nil { + for _, level := range *book.Asks { + table.Rows = append(table.Rows, []string{"ASK", publicStringValue(level.Price), publicStringValue(level.Amount)}) + } + } + return table +} + +func candlesTable(candles marketdata.CandleResponse) output.TableData { + table := output.TableData{Headers: []string{"TIMESTAMP", "OPEN", "HIGH", "LOW", "CLOSE", "VOLUME"}} + for _, candle := range candles { + row := make([]string, len(table.Headers)) + for i := range row { + if i < len(candle) { + row[i] = strconv.FormatFloat(candle[i], 'f', -1, 64) + } + } + table.Rows = append(table.Rows, row) + } + return table +} + +func publicStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func timestampValue(value *marketdata.TimestampType) string { + if value == nil { + return "" + } + encoded, err := json.Marshal(value) + if err != nil { + return "" + } + return strings.Trim(string(encoded), "\"") +} diff --git a/packages/gemini-markets-cli/internal/cli/public_predictions.go b/packages/gemini-markets-cli/internal/cli/public_predictions.go new file mode 100644 index 00000000..00a45e6f --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/public_predictions.go @@ -0,0 +1,414 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + "github.com/gemini/developer-platform/packages/sdk-go/generated/predictions" + "github.com/spf13/cobra" +) + +func newPublicPredictionMarketsCommand(publicFactory, authenticatedFactory PublicServiceFactory) *cobra.Command { + command := &cobra.Command{ + Use: "prediction-markets", + Aliases: []string{"predictions", "prediction"}, + Short: "Prediction-market discovery and terms", + Args: cobra.NoArgs, + } + command.AddCommand( + newPublicPredictionListCommand(publicFactory), + newPublicPredictionGetCommand(publicFactory), + newPredictionTermsCommand(publicFactory, authenticatedFactory), + ) + return command +} + +func newPublicPredictionListCommand(factory PublicServiceFactory) *cobra.Command { + var ( + statuses []string + categories []string + sports []string + marketTypes []string + subjects []string + scopes []string + metrics []string + search string + limit, offset int + ) + command := &cobra.Command{ + Use: "list", + Aliases: []string{"events"}, + Short: "List prediction-market events", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + params, err := listEventsParams(statuses, categories, sports, marketTypes, subjects, scopes, metrics, search, limit, offset) + if err != nil { + return err + } + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.Predictions == nil { + return fmt.Errorf("prediction markets service is unavailable") + } + events, err := services.Predictions.GetEvents(cmd.Context(), params) + if err != nil { + return fmt.Errorf("list prediction-market events: %w", err) + } + return writePublicResult(cmd, events, eventsTable(events)) + }) + }, + } + command.Flags().StringSliceVar(&statuses, "status", nil, "event status filter (repeatable: active, approved, closed, invalid, settled, under_review)") + command.Flags().StringSliceVar(&categories, "category", nil, "event category filter (repeatable)") + command.Flags().StringSliceVar(&sports, "sport", nil, "sports-market sport filter (repeatable)") + command.Flags().StringSliceVar(&marketTypes, "sports-market-type", nil, "sports-market type filter (repeatable)") + command.Flags().StringSliceVar(&subjects, "sports-market-subject", nil, "sports-market subject filter (repeatable)") + command.Flags().StringSliceVar(&scopes, "sports-market-scope", nil, "sports-market scope filter (repeatable)") + command.Flags().StringSliceVar(&metrics, "sports-market-metric", nil, "sports-market metric filter (repeatable)") + command.Flags().StringVar(&search, "search", "", "search event titles") + command.Flags().IntVar(&limit, "limit", -1, "maximum events to return (0-500; -1 omits the parameter)") + command.Flags().IntVar(&offset, "offset", -1, "number of events to skip (-1 omits the parameter)") + return command +} + +func newPublicPredictionGetCommand(factory PublicServiceFactory) *cobra.Command { + return &cobra.Command{ + Use: "get EVENT_TICKER", + Short: "Get one prediction-market event and its contracts", + Args: oneNonEmptyArgument("event ticker"), + RunE: func(cmd *cobra.Command, args []string) error { + ticker := strings.TrimSpace(args[0]) + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.Predictions == nil { + return fmt.Errorf("prediction markets service is unavailable") + } + event, err := services.Predictions.GetEvent(cmd.Context(), ticker) + if err != nil { + return fmt.Errorf("get prediction-market event %s: %w", ticker, err) + } + return writePublicResult(cmd, event, eventTable(event)) + }) + }, + } +} + +func newPredictionTermsCommand(publicFactory, authenticatedFactory PublicServiceFactory) *cobra.Command { + command := &cobra.Command{ + Use: "terms", + Short: "Read and manage prediction-market terms", + Args: cobra.NoArgs, + } + command.AddCommand( + newPredictionTermsShowCommand(publicFactory), + newPredictionTermsStatusCommand(authenticatedFactory), + newPredictionTermsAcceptCommand(authenticatedFactory), + ) + return command +} + +func newPredictionTermsShowCommand(factory PublicServiceFactory) *cobra.Command { + return &cobra.Command{ + Use: "show", + Aliases: []string{"view"}, + Short: "Show the latest prediction-market terms", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.Predictions == nil { + return fmt.Errorf("prediction markets service is unavailable") + } + terms, err := services.Predictions.GetTerms(cmd.Context()) + if err != nil { + return fmt.Errorf("get prediction-market terms: %w", err) + } + return writePredictionTerms(cmd, terms) + }) + }, + } +} + +func newPredictionTermsStatusCommand(factory PublicServiceFactory) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show prediction-market terms acceptance status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.Predictions == nil { + return fmt.Errorf("prediction markets service is unavailable") + } + status, err := services.Predictions.GetTermsStatus(cmd.Context()) + if err != nil { + return fmt.Errorf("get prediction-market terms status: %w", err) + } + return writePublicResult(cmd, status, predictionTermsStatusTable(status)) + }) + }, + } +} + +func newPredictionTermsAcceptCommand(factory PublicServiceFactory) *cobra.Command { + var yes bool + command := &cobra.Command{ + Use: "accept", + Short: "Explicitly accept the latest prediction-market terms", + Long: "Accept the latest prediction-market terms for the authenticated account group. Read the terms with gemini-markets prediction-markets terms show before accepting them.", + Example: " gemini-markets prediction-markets terms accept --yes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !yes { + return fmt.Errorf("accepting prediction-market terms requires --yes; review them with `gemini-markets prediction-markets terms show`") + } + return withPublicServices(cmd, factory, func(services PublicServices) error { + if services.Predictions == nil { + return fmt.Errorf("prediction markets service is unavailable") + } + response, err := services.Predictions.AcceptTerms(cmd.Context()) + if err != nil { + return fmt.Errorf("accept prediction-market terms: %w", err) + } + return writePublicResult(cmd, response, predictionTermsAcceptanceTable(response)) + }) + }, + } + command.Flags().BoolVar(&yes, "yes", false, "confirm acceptance of the latest terms") + return command +} + +func listEventsParams(statuses, categories, sports, marketTypes, subjects, scopes, metrics []string, search string, limit, offset int) (*predictions.ListEventsParams, error) { + if err := validateLimitOffset(limit, offset); err != nil { + return nil, err + } + parsedStatuses, err := parseMarketStatuses(statuses) + if err != nil { + return nil, err + } + parsedCategories, err := cleanValues("category", categories) + if err != nil { + return nil, err + } + parsedSports, err := parseEnumValues[predictions.SportsMarketSport]("sport", sports) + if err != nil { + return nil, err + } + parsedMarketTypes, err := parseEnumValues[predictions.SportsMarketType]("sports-market-type", marketTypes) + if err != nil { + return nil, err + } + parsedSubjects, err := parseEnumValues[predictions.SportsMarketSubject]("sports-market-subject", subjects) + if err != nil { + return nil, err + } + parsedScopes, err := parseEnumValues[predictions.SportsMarketScopeType]("sports-market-scope", scopes) + if err != nil { + return nil, err + } + parsedMetrics, err := parseEnumValues[predictions.SportsMarketMetric]("sports-market-metric", metrics) + if err != nil { + return nil, err + } + + params := &predictions.ListEventsParams{} + if len(parsedStatuses) > 0 { + params.Status = &parsedStatuses + } + if len(parsedCategories) > 0 { + params.Category = &parsedCategories + } + if len(parsedSports) > 0 { + values := predictions.SportFilter(parsedSports) + params.Sport = &values + } + if len(parsedMarketTypes) > 0 { + values := predictions.SportsMarketTypeFilter(parsedMarketTypes) + params.SportsMarketType = &values + } + if len(parsedSubjects) > 0 { + values := predictions.SportsMarketSubjectFilter(parsedSubjects) + params.SportsMarketSubject = &values + } + if len(parsedScopes) > 0 { + values := predictions.SportsMarketScopeFilter(parsedScopes) + params.SportsMarketScope = &values + } + if len(parsedMetrics) > 0 { + values := predictions.SportsMarketMetricFilter(parsedMetrics) + params.SportsMarketMetric = &values + } + if value := strings.TrimSpace(search); value != "" { + params.Search = &value + } + if limit >= 0 { + params.Limit = &limit + } + if offset >= 0 { + params.Offset = &offset + } + if paramsEmpty(params) { + return nil, nil + } + return params, nil +} + +func paramsEmpty(params *predictions.ListEventsParams) bool { + return params == nil || (params.Status == nil && params.Category == nil && params.Sport == nil && + params.SportsMarketType == nil && params.SportsMarketSubject == nil && params.SportsMarketScope == nil && + params.SportsMarketMetric == nil && params.Search == nil && params.Limit == nil && params.Offset == nil) +} + +func validateLimitOffset(limit, offset int) error { + if limit < -1 || limit > 500 { + return fmt.Errorf("limit must be -1 (omit) or between 0 and 500") + } + if offset < -1 { + return fmt.Errorf("offset must be -1 (omit) or non-negative") + } + return nil +} + +func parseMarketStatuses(values []string) ([]predictions.MarketStatus, error) { + cleaned, err := cleanValues("status", values) + if err != nil { + return nil, err + } + parsed := make([]predictions.MarketStatus, 0, len(cleaned)) + for _, value := range cleaned { + status := predictions.MarketStatus(strings.ToLower(value)) + switch status { + case predictions.MarketStatusActive, predictions.MarketStatusApproved, predictions.MarketStatusClosed, + predictions.MarketStatusInvalid, predictions.MarketStatusSettled, predictions.MarketStatusUnderReview: + parsed = append(parsed, status) + default: + return nil, fmt.Errorf("invalid status %q", value) + } + } + return parsed, nil +} + +func cleanValues(name string, values []string) ([]string, error) { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("%s values must not be empty", name) + } + cleaned = append(cleaned, value) + } + return cleaned, nil +} + +type validEnum interface { + ~string + Valid() bool +} + +func parseEnumValues[T validEnum](name string, values []string) ([]T, error) { + cleaned, err := cleanValues(name, values) + if err != nil { + return nil, err + } + parsed := make([]T, 0, len(cleaned)) + for _, value := range cleaned { + enumValue := T(value) + if !enumValue.Valid() { + return nil, fmt.Errorf("invalid %s %q", name, value) + } + parsed = append(parsed, enumValue) + } + return parsed, nil +} + +func eventsTable(events *predictions.EventsResponse) output.TableData { + table := output.TableData{Headers: []string{"TICKER", "TITLE", "CATEGORY", "STATUS", "TYPE", "VOLUME", "LIQUIDITY"}} + if events == nil || events.Data == nil { + return table + } + for _, event := range *events.Data { + table.Rows = append(table.Rows, []string{ + publicStringValue(event.Ticker), publicStringValue(event.Title), publicStringValue(event.Category), enumValue(event.Status), + enumValue(event.Type), publicStringValue(event.Volume), publicStringValue(event.Liquidity), + }) + } + return table +} + +func eventTable(event *predictions.Event) output.TableData { + headers := []string{"TICKER", "TITLE", "CATEGORY", "STATUS", "TYPE", "VOLUME", "LIQUIDITY", "CONTRACT"} + table := output.TableData{Headers: headers} + if event == nil { + return table + } + base := []string{publicStringValue(event.Ticker), publicStringValue(event.Title), publicStringValue(event.Category), enumValue(event.Status), enumValue(event.Type), publicStringValue(event.Volume), publicStringValue(event.Liquidity)} + if event.Contracts == nil || len(*event.Contracts) == 0 { + table.Rows = append(table.Rows, append(base, "")) + return table + } + for _, contract := range *event.Contracts { + table.Rows = append(table.Rows, append(base, publicStringValue(contract.InstrumentSymbol))) + } + return table +} + +func predictionTermsStatusTable(status *predictions.PredictionMarketsTermsStatus) output.TableData { + if status == nil { + return output.TableData{Headers: []string{"ACCEPTED_VERSION", "HAS_ACCEPTED_LATEST", "LATEST_VERSION"}} + } + acceptedVersion, latestVersion := "", "" + if status.AcceptedVersion != nil { + acceptedVersion = fmt.Sprintf("%d", *status.AcceptedVersion) + } + if status.LatestVersion != nil { + latestVersion = fmt.Sprintf("%d", *status.LatestVersion) + } + return output.TableData{ + Headers: []string{"ACCEPTED_VERSION", "HAS_ACCEPTED_LATEST", "LATEST_VERSION"}, + Rows: [][]string{{acceptedVersion, fmt.Sprintf("%t", status.HasAcceptedLatest), latestVersion}}, + } +} + +func writePredictionTerms(cmd *cobra.Command, terms *predictions.PredictionMarketsTerms) error { + if Options(cmd).Format == output.JSON { + return output.Write(cmd.OutOrStdout(), terms, output.JSON) + } + if terms == nil { + return output.Write(cmd.OutOrStdout(), output.TableData{Headers: []string{"VERSION", "TERMS TYPE", "UPDATED AT"}}, output.Table) + } + updatedAt := "" + if !terms.UpdatedAt.IsZero() { + updatedAt = terms.UpdatedAt.Format(time.RFC3339) + } + if err := output.Write(cmd.OutOrStdout(), output.TableData{ + Headers: []string{"VERSION", "TERMS TYPE", "UPDATED AT"}, + Rows: [][]string{{fmt.Sprintf("%d", terms.Version), terms.TermsType, updatedAt}}, + }, output.Table); err != nil { + return err + } + content := strings.TrimRight(terms.Content, "\r\n") + if content == "" { + return nil + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", content) + return err +} + +func predictionTermsAcceptanceTable(response *predictions.AcceptPredictionMarketsTermsResponse) output.TableData { + if response == nil { + return output.TableData{Headers: []string{"SUCCESS"}} + } + return output.TableData{Headers: []string{"SUCCESS"}, Rows: [][]string{{fmt.Sprintf("%t", response.Success)}}} +} + +func enumValue[T ~string](value *T) string { + if value == nil { + return "" + } + return string(*value) +} + +func timeValue(value *time.Time) string { + if value == nil { + return "" + } + return value.Format(time.RFC3339) +} diff --git a/packages/gemini-markets-cli/internal/cli/public_test.go b/packages/gemini-markets-cli/internal/cli/public_test.go new file mode 100644 index 00000000..8bfeafd0 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/public_test.go @@ -0,0 +1,303 @@ +package cli + +import ( + "bytes" + "context" + "reflect" + "strings" + "testing" + + "github.com/gemini/developer-platform/packages/sdk-go/generated/marketdata" + "github.com/gemini/developer-platform/packages/sdk-go/generated/predictions" + "github.com/spf13/cobra" +) + +type publicFakeServices struct { + book *marketdata.OrderBook + symbols []string + candles marketdata.CandleResponse + events *predictions.EventsResponse + termsStatus *predictions.PredictionMarketsTermsStatus + terms *predictions.PredictionMarketsTerms + termsAccept *predictions.AcceptPredictionMarketsTermsResponse + + bookSymbol string + bookBids int + bookAsks int + eventParam *predictions.ListEventsParams + candleSymbol string + candleTimeframe string +} + +func (f *publicFakeServices) GetSymbols(context.Context) ([]string, error) { return f.symbols, nil } + +func (f *publicFakeServices) GetTicker(context.Context, string) (*marketdata.Ticker, error) { + return &marketdata.Ticker{}, nil +} + +func (f *publicFakeServices) GetOrderBook(_ context.Context, symbol string, bids, asks int) (*marketdata.OrderBook, error) { + f.bookSymbol, f.bookBids, f.bookAsks = symbol, bids, asks + return f.book, nil +} + +func (f *publicFakeServices) GetCandles(_ context.Context, symbol, timeframe string) (marketdata.CandleResponse, error) { + f.candleSymbol, f.candleTimeframe = symbol, timeframe + return f.candles, nil +} + +func (f *publicFakeServices) GetEvents(_ context.Context, params *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + f.eventParam = params + return f.events, nil +} + +func (f *publicFakeServices) GetEvent(context.Context, string) (*predictions.Event, error) { + return &predictions.Event{}, nil +} + +type predictionFake struct { + *publicFakeServices +} + +func (f *predictionFake) GetTermsStatus(context.Context) (*predictions.PredictionMarketsTermsStatus, error) { + return f.termsStatus, nil +} + +func (f *predictionFake) GetTerms(context.Context) (*predictions.PredictionMarketsTerms, error) { + return f.terms, nil +} + +func (f *predictionFake) AcceptTerms(context.Context) (*predictions.AcceptPredictionMarketsTermsResponse, error) { + return f.termsAccept, nil +} + +func testPublicRoot(out *bytes.Buffer, factory PublicServiceFactory) *cobra.Command { + root := newTestRootCommand(out, out) + root.AddCommand(NewMarketsCommand(factory), NewPredictionMarketsCommand(factory)) + return root +} + +func TestMarketsBookMapsLimitsAndRendersTable(t *testing.T) { + var out bytes.Buffer + fake := &publicFakeServices{book: &marketdata.OrderBook{}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{MarketData: fake}, nil + }) + root.SetArgs([]string{"markets", "book", "BTCUSD", "--limit-bids", "2", "--limit-asks", "3"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.bookSymbol != "BTCUSD" || fake.bookBids != 2 || fake.bookAsks != 3 { + t.Fatalf("book request = (%q, %d, %d)", fake.bookSymbol, fake.bookBids, fake.bookAsks) + } + if !strings.Contains(out.String(), "SIDE") { + t.Fatalf("table output = %q, want SIDE header", out.String()) + } +} + +func TestPredictionListMapsSDKRequestTypes(t *testing.T) { + var out bytes.Buffer + fake := &predictionFake{publicFakeServices: &publicFakeServices{events: &predictions.EventsResponse{}}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{Predictions: fake}, nil + }) + root.SetArgs([]string{ + "prediction-markets", "list", "--status", "active", "--category", "Sports", "--sport", "soccer", + "--sports-market-type", "moneyline", "--sports-market-subject", "contest", "--sports-market-scope", "game", + "--sports-market-metric", "goals", "--search", "final", "--limit", "25", "--offset", "5", + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.eventParam == nil { + t.Fatal("GetEvents received nil params") + } + wantStatuses := []predictions.MarketStatus{predictions.MarketStatusActive} + if !reflect.DeepEqual(*fake.eventParam.Status, wantStatuses) || *fake.eventParam.Category == nil || (*fake.eventParam.Category)[0] != "Sports" { + t.Fatalf("event params = %#v", fake.eventParam) + } + wantSports := predictions.SportFilter{predictions.Soccer} + wantMarketTypes := predictions.SportsMarketTypeFilter{predictions.SportsMarketTypeMoneyline} + wantSubjects := predictions.SportsMarketSubjectFilter{predictions.SportsMarketSubjectContest} + wantScopes := predictions.SportsMarketScopeFilter{predictions.SportsMarketScopeTypeGame} + wantMetrics := predictions.SportsMarketMetricFilter{predictions.SportsMarketMetricGoals} + if fake.eventParam.Sport == nil || !reflect.DeepEqual(*fake.eventParam.Sport, wantSports) || + fake.eventParam.SportsMarketType == nil || !reflect.DeepEqual(*fake.eventParam.SportsMarketType, wantMarketTypes) || + fake.eventParam.SportsMarketSubject == nil || !reflect.DeepEqual(*fake.eventParam.SportsMarketSubject, wantSubjects) || + fake.eventParam.SportsMarketScope == nil || !reflect.DeepEqual(*fake.eventParam.SportsMarketScope, wantScopes) || + fake.eventParam.SportsMarketMetric == nil || !reflect.DeepEqual(*fake.eventParam.SportsMarketMetric, wantMetrics) { + t.Fatalf("sports-market filters = %#v", fake.eventParam) + } + if fake.eventParam.Limit == nil || *fake.eventParam.Limit != 25 || fake.eventParam.Offset == nil || *fake.eventParam.Offset != 5 { + t.Fatalf("pagination params = %#v", fake.eventParam) + } + if fake.eventParam.Search == nil || *fake.eventParam.Search != "final" { + t.Fatalf("search params = %#v", fake.eventParam) + } +} + +func TestPredictionListRejectsInvalidEnumFilters(t *testing.T) { + tests := []struct { + name string + flag string + value string + }{ + {name: "sport", flag: "--sport", value: "not-a-sport"}, + {name: "sports-market-type", flag: "--sports-market-type", value: "not-a-type"}, + {name: "sports-market-subject", flag: "--sports-market-subject", value: "not-a-subject"}, + {name: "sports-market-scope", flag: "--sports-market-scope", value: "not-a-scope"}, + {name: "sports-market-metric", flag: "--sports-market-metric", value: "not-a-metric"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + fake := &predictionFake{publicFakeServices: &publicFakeServices{events: &predictions.EventsResponse{}}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{Predictions: fake}, nil + }) + root.SetArgs([]string{"prediction-markets", "list", test.flag, test.value}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "invalid "+test.name) { + t.Fatalf("Execute() error = %v, want invalid %s", err, test.name) + } + if fake.eventParam != nil { + t.Fatalf("GetEvents called with params = %#v", fake.eventParam) + } + }) + } +} + +func TestPublicSymbolsJSONOutput(t *testing.T) { + var out bytes.Buffer + fake := &publicFakeServices{symbols: []string{"BTCUSD", "ETHUSD"}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{MarketData: fake}, nil + }) + root.SetArgs([]string{"--output", "json", "markets", "symbols"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(out.String(), `"BTCUSD"`) || !strings.Contains(out.String(), `"ETHUSD"`) { + t.Fatalf("JSON output = %q", out.String()) + } +} + +func TestCandlesValidatesOfficialTimeframes(t *testing.T) { + tests := map[string]string{ + "1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m", + "1h": "1hr", "1hr": "1hr", "6h": "6hr", "6hr": "6hr", + "1d": "1day", "1day": "1day", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + var out bytes.Buffer + fake := &publicFakeServices{candles: marketdata.CandleResponse{{1, 2, 3, 4, 5, 6}}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{MarketData: fake}, nil + }) + root.SetArgs([]string{"markets", "candles", "BTCUSD", input}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.candleSymbol != "BTCUSD" || fake.candleTimeframe != want { + t.Fatalf("candle request = (%q, %q), want (BTCUSD, %s)", fake.candleSymbol, fake.candleTimeframe, want) + } + }) + } + + var out bytes.Buffer + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{MarketData: &publicFakeServices{}}, nil + }) + root.SetArgs([]string{"markets", "candles", "BTCUSD", "2h"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "invalid timeframe") { + t.Fatalf("Execute() error = %v, want invalid timeframe", err) + } +} + +func TestPredictionTermsCommandsUseExplicitSDKMethods(t *testing.T) { + var out bytes.Buffer + fake := &predictionFake{publicFakeServices: &publicFakeServices{ + termsStatus: &predictions.PredictionMarketsTermsStatus{HasAcceptedLatest: false}, + terms: &predictions.PredictionMarketsTerms{Version: 3, TermsType: "prediction", Content: "Terms content"}, + termsAccept: &predictions.AcceptPredictionMarketsTermsResponse{Success: true}, + }} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + return PublicServices{Predictions: fake}, nil + }) + root.SetArgs([]string{"prediction-markets", "terms", "show"}) + if err := root.Execute(); err != nil { + t.Fatalf("show Execute() error = %v", err) + } + if !strings.Contains(out.String(), "Terms content") || !strings.Contains(out.String(), "VERSION") { + t.Fatalf("show output = %q", out.String()) + } + + out.Reset() + root.SetArgs([]string{"prediction-markets", "terms", "status"}) + if err := root.Execute(); err != nil { + t.Fatalf("status Execute() error = %v", err) + } + if !strings.Contains(out.String(), "HAS_ACCEPTED_LATEST") { + t.Fatalf("status output = %q", out.String()) + } + + out.Reset() + root.SetArgs([]string{"prediction-markets", "terms", "accept", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatalf("accept Execute() error = %v", err) + } + if !strings.Contains(out.String(), "SUCCESS") { + t.Fatalf("accept output = %q", out.String()) + } +} + +func TestPredictionTermsAcceptRequiresExplicitConfirmation(t *testing.T) { + var out bytes.Buffer + called := false + fake := &predictionFake{publicFakeServices: &publicFakeServices{termsAccept: &predictions.AcceptPredictionMarketsTermsResponse{Success: true}}} + root := testPublicRoot(&out, func(context.Context, GlobalOptions) (PublicServices, error) { + called = true + return PublicServices{Predictions: fake}, nil + }) + root.SetArgs([]string{"prediction-markets", "terms", "accept"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "requires --yes") { + t.Fatalf("Execute() error = %v, want confirmation error", err) + } + if called { + t.Fatal("service factory called before terms acceptance was confirmed") + } +} + +func TestPredictionTermsUsePublicReadAndAuthenticatedStateFactories(t *testing.T) { + var out bytes.Buffer + publicCalls, authenticatedCalls := 0, 0 + publicFake := &predictionFake{publicFakeServices: &publicFakeServices{ + terms: &predictions.PredictionMarketsTerms{Version: 3, Content: "Terms content"}, + }} + authenticatedFake := &predictionFake{publicFakeServices: &publicFakeServices{ + termsStatus: &predictions.PredictionMarketsTermsStatus{HasAcceptedLatest: true}, + }} + publicFactory := func(context.Context, GlobalOptions) (PublicServices, error) { + publicCalls++ + return PublicServices{Predictions: publicFake}, nil + } + authenticatedFactory := func(context.Context, GlobalOptions) (PublicServices, error) { + authenticatedCalls++ + return PublicServices{Predictions: authenticatedFake}, nil + } + root := newTestRootCommand(&out, &out) + root.AddCommand(newPublicPredictionMarketsCommand(publicFactory, authenticatedFactory)) + + root.SetArgs([]string{"prediction-markets", "terms", "show"}) + if err := root.Execute(); err != nil { + t.Fatalf("show Execute() error = %v", err) + } + root.SetArgs([]string{"prediction-markets", "terms", "status"}) + if err := root.Execute(); err != nil { + t.Fatalf("status Execute() error = %v", err) + } + if publicCalls != 1 || authenticatedCalls != 1 { + t.Fatalf("factory calls = public %d, authenticated %d; want 1 each", publicCalls, authenticatedCalls) + } +} diff --git a/packages/gemini-markets-cli/internal/cli/root.go b/packages/gemini-markets-cli/internal/cli/root.go new file mode 100644 index 00000000..378ca2e0 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/root.go @@ -0,0 +1,155 @@ +// Package cli defines the root command and global flags. Domain command +// groups should be added by composing children here; they should create a +// session and call SDK services directly in their RunE functions. +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" + "github.com/spf13/cobra" +) + +// Version is replaced by release builds when desired. +var Version = "dev" + +// GlobalOptions are the values shared by all command groups. +type GlobalOptions struct { + Environment string + Profile string + Format output.Format +} + +// NewRootCommand creates the standalone gemini-markets command and registers +// all command groups supported by the CLI. +func NewRootCommand(stdout, stderr io.Writer) *cobra.Command { + var options GlobalOptions + options.Environment = "production" + options.Profile = "default" + options.Format = output.Table + + command := &cobra.Command{ + Use: "gemini-markets", + Short: "Gemini Markets command-line client", + Long: "Gemini Markets command-line client for public market data, authenticated trading, account inspection, and real-time streams.", + Example: " gemini-markets markets ticker BTCUSD\n" + + " gemini-markets --environment sandbox --profile trader account balances\n" + + " gemini-markets orders spot place --symbol BTCUSD --side buy --amount 0.01 --price 60000 --dry-run\n" + + " gemini-markets stream trades BTCUSD", + Version: Version, + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + format, err := output.ParseFormat(string(options.Format)) + if err != nil { + return err + } + options.Format = format + options.Environment = strings.ToLower(strings.TrimSpace(options.Environment)) + if options.Environment == "" { + options.Environment = "production" + } + if options.Environment != "production" && options.Environment != "sandbox" { + return fmt.Errorf("invalid environment %q (want production or sandbox)", options.Environment) + } + options.Profile = strings.TrimSpace(options.Profile) + if options.Profile == "" { + options.Profile = "default" + } + return nil + }, + } + if stdout != nil { + command.SetOut(stdout) + } + if stderr != nil { + command.SetErr(stderr) + } + command.PersistentFlags().StringVarP(&options.Environment, "environment", "e", options.Environment, "Gemini environment (production or sandbox)") + command.PersistentFlags().StringVarP(&options.Profile, "profile", "p", options.Profile, "credential profile") + command.PersistentFlags().Var(formatValue{value: &options.Format}, "output", "output format (table or json)") + command.AddCommand( + NewMarketsCommand(), + NewPredictionMarketsCommand(), + NewAccountCommand(), + NewOrdersCommand(), + NewStreamCommand(), + NewAuthCommand(), + ) + command.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print the CLI version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if options.Format == output.JSON { + return output.Write(cmd.OutOrStdout(), struct { + Name string `json:"name"` + Version string `json:"version"` + }{"gemini-markets", Version}, output.JSON) + } + _, err := fmt.Fprintln(cmd.OutOrStdout(), Version) + return err + }, + }) + return command +} + +// Options returns the parsed global values. It is intended for future child +// commands and does not perform credential loading itself. +func Options(command *cobra.Command) GlobalOptions { + options := GlobalOptions{Environment: "production", Profile: "default", Format: output.Table} + if value, err := command.Flags().GetString("environment"); err == nil { + options.Environment = value + } + if value, err := command.Flags().GetString("profile"); err == nil { + options.Profile = value + } + if flag := command.Flags().Lookup("output"); flag != nil { + if format, parseErr := output.ParseFormat(flag.Value.String()); parseErr == nil { + options.Format = format + } + } + return options +} + +func credentialProfile(options GlobalOptions) string { + profile := strings.TrimSpace(options.Profile) + if profile == "" { + profile = "default" + } + environment := strings.ToLower(strings.TrimSpace(options.Environment)) + if environment == "" { + environment = "production" + } + return profile + "@" + environment +} + +type formatValue struct{ value *output.Format } + +func (f formatValue) String() string { return string(*f.value) } + +func (f formatValue) Set(value string) error { + format, err := output.ParseFormat(value) + if err != nil { + return err + } + *f.value = format + return nil +} + +func (f formatValue) Type() string { return "format" } + +// Execute is a convenience for embedders that want a context and argument +// slice without managing Cobra's command object directly. +func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) error { + command := NewRootCommand(stdout, stderr) + command.SetArgs(args) + return command.ExecuteContext(ctx) +} diff --git a/packages/gemini-markets-cli/internal/cli/root_test.go b/packages/gemini-markets-cli/internal/cli/root_test.go new file mode 100644 index 00000000..d97272e2 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/root_test.go @@ -0,0 +1,73 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/output" +) + +func TestRootCommandShowsDiscoverableHelp(t *testing.T) { + var out bytes.Buffer + command := NewRootCommand(&out, &out) + command.SetArgs(nil) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + for _, want := range []string{"Usage:", "Available Commands:", "gemini-markets markets ticker BTCUSD"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output = %q, want %q", out.String(), want) + } + } +} + +func TestRootCommandParsesJSONOutput(t *testing.T) { + command := NewRootCommand(io.Discard, io.Discard) + command.SetArgs([]string{"--output", "json", "version"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := Options(command).Format; got != output.JSON { + t.Fatalf("Options().Format = %q, want json", got) + } +} + +func TestRootCommandVersionSubcommand(t *testing.T) { + var out bytes.Buffer + command := NewRootCommand(&out, &out) + command.SetArgs([]string{"version"}) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got, want := out.String(), Version+"\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestRootCommandRegistersCommandGroups(t *testing.T) { + command := NewRootCommand(nil, nil) + want := []string{"markets", "prediction-markets", "account", "orders", "stream", "auth"} + for _, name := range want { + found := false + for _, child := range command.Commands() { + if child.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("root command is missing %q", name) + } + } +} + +func TestRootCommandRejectsUnknownEnvironment(t *testing.T) { + var out bytes.Buffer + command := NewRootCommand(&out, &out) + command.SetArgs([]string{"--environment", "staging"}) + if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "invalid environment") { + t.Fatalf("Execute() error = %v, want invalid environment", err) + } +} diff --git a/packages/gemini-markets-cli/internal/cli/stream.go b/packages/gemini-markets-cli/internal/cli/stream.go new file mode 100644 index 00000000..9e733184 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/stream.go @@ -0,0 +1,403 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/session" + gemini "github.com/gemini/developer-platform/packages/sdk-go" + ws "github.com/gemini/developer-platform/packages/sdk-go/websocket" + "github.com/spf13/cobra" +) + +// PublicStreamClient is the typed public WebSocket surface consumed by the +// stream commands. Keeping this interface at the command boundary makes tests +// independent from a network transport while leaving protocol handling to the +// official SDK. +type PublicStreamClient interface { + SubscribeTrades(context.Context, string) (<-chan *ws.TradeEvent, error) + SubscribeBookTicker(context.Context, string) (<-chan *ws.BookTicker, error) + SubscribeDepthWithOptions(context.Context, string, ws.DepthSubscriptionOptions) (<-chan *ws.DepthUpdate, error) + SubscribePartialDepth(context.Context, string, ws.PartialDepthSubscriptionOptions) (<-chan *ws.OrderBookSnapshot, error) +} + +// PrivateStreamClient is the typed authenticated WebSocket surface consumed +// by private stream commands. +type PrivateStreamClient interface { + SubscribeOrderEventsWithScope(context.Context, ws.SubscriptionScope) (<-chan *ws.OrderEvent, error) + SubscribeBalancesWithOptions(context.Context, ws.AccountStreamOptions) (<-chan *ws.BalanceUpdate, error) + SubscribePositionsWithOptions(context.Context, ws.AccountStreamOptions) (<-chan *ws.PositionReport, error) +} + +// PublicStreamFactory creates a public typed WebSocket client and its owner +// for one command invocation. The owner is closed when streaming ends. +type PublicStreamFactory func(context.Context, GlobalOptions) (PublicStreamClient, io.Closer, error) + +// PrivateStreamFactory creates an authenticated typed WebSocket client and +// its owner for one command invocation. The owner is closed when streaming +// ends. +type PrivateStreamFactory func(context.Context, GlobalOptions) (PrivateStreamClient, io.Closer, error) + +func defaultPublicStreamFactory(ctx context.Context, options GlobalOptions) (PublicStreamClient, io.Closer, error) { + valueSession, err := session.New(session.Config{ + Environment: gemini.Environment(options.Environment), + EnableWebSockets: true, + }) + if err != nil { + return nil, nil, err + } + return valueSession.Client.PublicWebSocket(), closeSession(valueSession), nil +} + +func defaultPrivateStreamFactory(ctx context.Context, options GlobalOptions) (PrivateStreamClient, io.Closer, error) { + config, err := privateSessionConfig(ctx, options) + if err != nil { + return nil, nil, err + } + config.EnableWebSockets = true + config.PrivateWebSockets = true + valueSession, err := session.New(config) + if err != nil { + return nil, nil, err + } + return valueSession.Client.PrivateWebSocket(), closeSession(valueSession), nil +} + +// NewStreamCommand creates the WebSocket stream command tree. Root command +// wiring intentionally remains in root.go's owner: callers should register +// the returned command with root.AddCommand(NewStreamCommand()). +func NewStreamCommand() *cobra.Command { + return NewStreamCommandWithFactories(defaultPublicStreamFactory, defaultPrivateStreamFactory) +} + +// NewStreamsCommand is a plural-name convenience alias for NewStreamCommand. +func NewStreamsCommand() *cobra.Command { return NewStreamCommand() } + +// NewStreamCommandWithFactories creates the stream tree with injected typed +// SDK consumers. It is primarily useful for focused command tests. +func NewStreamCommandWithFactories(publicFactory PublicStreamFactory, privateFactory PrivateStreamFactory) *cobra.Command { + if publicFactory == nil { + publicFactory = defaultPublicStreamFactory + } + if privateFactory == nil { + privateFactory = defaultPrivateStreamFactory + } + + command := &cobra.Command{ + Use: "stream", + Aliases: []string{"streams"}, + Short: "Stream real-time market and account data", + Long: "Stream real-time market and account data as newline-delimited JSON (one event per line) until interrupted. Stream output is always NDJSON; the global --output flag does not apply.", + Args: cobra.NoArgs, + } + command.AddCommand( + newTradesStreamCommand(publicFactory), + newTickerStreamCommand(publicFactory), + newDepthStreamCommand(publicFactory), + newOrdersStreamCommand(privateFactory), + newBalancesStreamCommand(privateFactory), + newPositionsStreamCommand(privateFactory), + ) + return command +} + +// NewStreamCommandWithFactory is the public-only injection convenience. The +// private children retain their default authenticated factory. +func NewStreamCommandWithFactory(publicFactory PublicStreamFactory) *cobra.Command { + return NewStreamCommandWithFactories(publicFactory, nil) +} + +func newTradesStreamCommand(factory PublicStreamFactory) *cobra.Command { + return &cobra.Command{ + Use: "trades SYMBOL", + Aliases: []string{"trade"}, + Short: "Stream public trades for a symbol", + Args: oneNonEmptyArgument("symbol"), + RunE: func(cmd *cobra.Command, args []string) error { + symbol := strings.TrimSpace(args[0]) + return withPublicStream(cmd, factory, func(ctx context.Context, client PublicStreamClient, output io.Writer) error { + events, err := client.SubscribeTrades(ctx, symbol) + if err != nil { + return fmt.Errorf("subscribe trades for %s: %w", symbol, err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } +} + +func newTickerStreamCommand(factory PublicStreamFactory) *cobra.Command { + return &cobra.Command{ + Use: "ticker SYMBOL", + Aliases: []string{"book-ticker", "bookticker", "bbo"}, + Short: "Stream public best bid and offer updates for a symbol", + Args: oneNonEmptyArgument("symbol"), + RunE: func(cmd *cobra.Command, args []string) error { + symbol := strings.TrimSpace(args[0]) + return withPublicStream(cmd, factory, func(ctx context.Context, client PublicStreamClient, output io.Writer) error { + events, err := client.SubscribeBookTicker(ctx, symbol) + if err != nil { + return fmt.Errorf("subscribe book ticker for %s: %w", symbol, err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } +} + +func newDepthStreamCommand(factory PublicStreamFactory) *cobra.Command { + var levels int + var interval time.Duration + command := &cobra.Command{ + Use: "depth SYMBOL", + Aliases: []string{"order-book"}, + Short: "Stream public order-book depth updates for a symbol", + Args: oneNonEmptyArgument("symbol"), + RunE: func(cmd *cobra.Command, args []string) error { + if levels != 0 && levels != 5 && levels != 10 && levels != 20 { + return fmt.Errorf("depth levels must be 0, 5, 10, or 20") + } + if interval != 0 && interval != 100*time.Millisecond { + return fmt.Errorf("depth interval must be 0 or 100ms") + } + symbol := strings.TrimSpace(args[0]) + return withPublicStream(cmd, factory, func(ctx context.Context, client PublicStreamClient, output io.Writer) error { + if levels == 0 { + events, err := client.SubscribeDepthWithOptions(ctx, symbol, ws.DepthSubscriptionOptions{Interval: interval}) + if err != nil { + return fmt.Errorf("subscribe depth for %s: %w", symbol, err) + } + return consumeClientStream(ctx, output, client, events) + } + events, err := client.SubscribePartialDepth(ctx, symbol, ws.PartialDepthSubscriptionOptions{ + Levels: ws.PartialDepthLevel(levels), + Interval: interval, + }) + if err != nil { + return fmt.Errorf("subscribe depth for %s: %w", symbol, err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } + command.Flags().IntVar(&levels, "levels", 0, "partial-depth levels (0 for differential depth; use 5, 10, or 20)") + command.Flags().DurationVar(&interval, "interval", 0, "update interval (0 or 100ms)") + return command +} + +func newOrdersStreamCommand(factory PrivateStreamFactory) *cobra.Command { + var scope string + command := &cobra.Command{ + Use: "orders", + Aliases: []string{"order"}, + Short: "Stream authenticated order lifecycle updates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + normalizedScope := strings.ToLower(strings.TrimSpace(scope)) + if normalizedScope != string(ws.ScopeAccount) && normalizedScope != string(ws.ScopeSession) { + return fmt.Errorf("order stream scope must be account or session") + } + return withPrivateStream(cmd, factory, func(ctx context.Context, client PrivateStreamClient, output io.Writer) error { + events, err := client.SubscribeOrderEventsWithScope(ctx, ws.SubscriptionScope(normalizedScope)) + if err != nil { + return fmt.Errorf("subscribe order events: %w", err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } + command.Flags().StringVar(&scope, "scope", string(ws.ScopeAccount), "order stream scope (account or session)") + return command +} + +func newBalancesStreamCommand(factory PrivateStreamFactory) *cobra.Command { + var interval time.Duration + command := &cobra.Command{ + Use: "balances", + Short: "Stream authenticated balance updates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if interval != 0 && interval != time.Second { + return fmt.Errorf("balance stream interval must be 0 or 1s") + } + return withPrivateStream(cmd, factory, func(ctx context.Context, client PrivateStreamClient, output io.Writer) error { + events, err := client.SubscribeBalancesWithOptions(ctx, ws.AccountStreamOptions{Interval: interval}) + if err != nil { + return fmt.Errorf("subscribe balances: %w", err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } + command.Flags().DurationVar(&interval, "interval", 0, "update interval (0 or 1s)") + return command +} + +func newPositionsStreamCommand(factory PrivateStreamFactory) *cobra.Command { + var interval time.Duration + command := &cobra.Command{ + Use: "positions", + Short: "Stream authenticated position updates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if interval != 0 && interval != time.Second { + return fmt.Errorf("position stream interval must be 0 or 1s") + } + return withPrivateStream(cmd, factory, func(ctx context.Context, client PrivateStreamClient, output io.Writer) error { + events, err := client.SubscribePositionsWithOptions(ctx, ws.AccountStreamOptions{Interval: interval}) + if err != nil { + return fmt.Errorf("subscribe positions: %w", err) + } + return consumeClientStream(ctx, output, client, events) + }) + }, + } + command.Flags().DurationVar(&interval, "interval", 0, "update interval (0 or 1s)") + return command +} + +type streamRun func(context.Context, io.Writer) error + +// connectionEventSource is the terminal-status seam exposed by the SDK's +// typed WebSocket client. A feed channel carries values only, so its closure +// cannot preserve the reason a resilient connection stopped. The SDK reports +// terminal failures as a ConnectionEvent with StateDisconnected (and Err), +// which the CLI observes when the concrete client supports this API. +type connectionEventSource interface { + SubscribeConnectionEvents(buffer int) (<-chan ws.ConnectionEvent, func()) +} + +func withPublicStream(cmd *cobra.Command, factory PublicStreamFactory, run func(context.Context, PublicStreamClient, io.Writer) error) error { + if factory == nil { + return errors.New("public stream factory is nil") + } + return withStreamContext(cmd, func(ctx context.Context, output io.Writer) error { + client, owner, err := factory(ctx, Options(cmd)) + if err != nil { + return fmt.Errorf("create public WebSocket session: %w", err) + } + if client == nil { + if owner != nil { + _ = owner.Close() + } + return errors.New("public WebSocket client is unavailable") + } + return withStreamOwner(owner, func() error { return run(ctx, client, output) }) + }) +} + +func withPrivateStream(cmd *cobra.Command, factory PrivateStreamFactory, run func(context.Context, PrivateStreamClient, io.Writer) error) error { + if factory == nil { + return errors.New("private stream factory is nil") + } + return withStreamContext(cmd, func(ctx context.Context, output io.Writer) error { + client, owner, err := factory(ctx, Options(cmd)) + if err != nil { + return fmt.Errorf("create private WebSocket session: %w", err) + } + if client == nil { + if owner != nil { + _ = owner.Close() + } + return errors.New("private WebSocket client is unavailable") + } + return withStreamOwner(owner, func() error { return run(ctx, client, output) }) + }) +} + +func withStreamOwner(owner io.Closer, run func() error) (err error) { + if owner != nil { + defer func() { + if closeErr := owner.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close WebSocket session: %w", closeErr) + } + }() + } + return run() +} + +func withStreamContext(cmd *cobra.Command, run streamRun) error { + parent := context.Background() + if cmd != nil && cmd.Context() != nil { + parent = cmd.Context() + } + ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) + defer stop() + return run(ctx, cmd.OutOrStdout()) +} + +func consumeClientStream[T any](ctx context.Context, output io.Writer, client any, events <-chan *T) error { + var connectionEvents <-chan ws.ConnectionEvent + stopConnectionEvents := func() {} + if source, ok := client.(connectionEventSource); ok { + connectionEvents, stopConnectionEvents = source.SubscribeConnectionEvents(16) + } + defer stopConnectionEvents() + return consumeStream(ctx, output, events, connectionEvents) +} + +// consumeStream emits one compact JSON value per line. A cancelled command is +// a successful end to a stream; this keeps Ctrl-C from producing a misleading +// command error while still allowing subscription failures, terminal SDK +// connection errors, and write errors to reach the caller. A closed feed +// without cancellation is always abnormal, including when a custom client +// does not expose the SDK connection-event API. +func consumeStream[T any](ctx context.Context, output io.Writer, events <-chan *T, connectionEvents ...<-chan ws.ConnectionEvent) error { + if ctx == nil { + ctx = context.Background() + } + if events == nil { + return errors.New("WebSocket subscription returned a nil channel") + } + var lifecycleEvents <-chan ws.ConnectionEvent + if len(connectionEvents) > 0 { + lifecycleEvents = connectionEvents[0] + } + encoder := json.NewEncoder(output) + for { + if ctx.Err() != nil { + return nil + } + select { + case <-ctx.Done(): + return nil + case event, ok := <-events: + if !ok { + if ctx.Err() != nil { + return nil + } + return errors.New("WebSocket stream closed unexpectedly") + } + if ctx.Err() != nil { + return nil + } + if err := encoder.Encode(event); err != nil { + return fmt.Errorf("write stream event: %w", err) + } + case lifecycleEvent, ok := <-lifecycleEvents: + if !ok { + if ctx.Err() != nil { + return nil + } + return errors.New("WebSocket lifecycle event stream closed unexpectedly") + } + if ctx.Err() != nil { + return nil + } + if lifecycleEvent.State == ws.StateDisconnected || lifecycleEvent.State == ws.StateClosed { + if lifecycleEvent.Err != nil { + return fmt.Errorf("WebSocket stream terminated: %w", lifecycleEvent.Err) + } + return errors.New("WebSocket stream closed unexpectedly") + } + } + } +} diff --git a/packages/gemini-markets-cli/internal/cli/stream_test.go b/packages/gemini-markets-cli/internal/cli/stream_test.go new file mode 100644 index 00000000..4d1dfc4e --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/stream_test.go @@ -0,0 +1,258 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "sync" + "testing" + "time" + + ws "github.com/gemini/developer-platform/packages/sdk-go/websocket" +) + +type streamPublicFake struct { + trades chan *ws.TradeEvent + ticker chan *ws.BookTicker + depth chan *ws.DepthUpdate + partialDepth chan *ws.OrderBookSnapshot + + tradesSymbol string + tickerSymbol string + depthSymbol string + depthOptions ws.DepthSubscriptionOptions + partialSym string + partialOpts ws.PartialDepthSubscriptionOptions +} + +func (f *streamPublicFake) SubscribeTrades(_ context.Context, symbol string) (<-chan *ws.TradeEvent, error) { + f.tradesSymbol = symbol + return f.trades, nil +} + +func (f *streamPublicFake) SubscribeBookTicker(_ context.Context, symbol string) (<-chan *ws.BookTicker, error) { + f.tickerSymbol = symbol + return f.ticker, nil +} + +func (f *streamPublicFake) SubscribeDepthWithOptions(_ context.Context, symbol string, options ws.DepthSubscriptionOptions) (<-chan *ws.DepthUpdate, error) { + f.depthSymbol, f.depthOptions = symbol, options + return f.depth, nil +} + +func (f *streamPublicFake) SubscribePartialDepth(_ context.Context, symbol string, options ws.PartialDepthSubscriptionOptions) (<-chan *ws.OrderBookSnapshot, error) { + f.partialSym, f.partialOpts = symbol, options + return f.partialDepth, nil +} + +type streamPrivateFake struct { + orders chan *ws.OrderEvent + balances chan *ws.BalanceUpdate + positions chan *ws.PositionReport + + orderScope ws.SubscriptionScope + balanceOpts ws.AccountStreamOptions + positionOpts ws.AccountStreamOptions +} + +func (f *streamPrivateFake) SubscribeOrderEventsWithScope(_ context.Context, scope ws.SubscriptionScope) (<-chan *ws.OrderEvent, error) { + f.orderScope = scope + return f.orders, nil +} + +func (f *streamPrivateFake) SubscribeBalancesWithOptions(_ context.Context, options ws.AccountStreamOptions) (<-chan *ws.BalanceUpdate, error) { + f.balanceOpts = options + return f.balances, nil +} + +func (f *streamPrivateFake) SubscribePositionsWithOptions(_ context.Context, options ws.AccountStreamOptions) (<-chan *ws.PositionReport, error) { + f.positionOpts = options + return f.positions, nil +} + +type streamCloser struct{ closed bool } + +func (c *streamCloser) Close() error { + c.closed = true + return nil +} + +type cancelOnWrite struct { + io.Writer + cancel context.CancelFunc + cancelOne sync.Once +} + +func (w *cancelOnWrite) Write(value []byte) (int, error) { + n, err := w.Writer.Write(value) + w.cancelOne.Do(w.cancel) + return n, err +} + +func TestStreamTradesUsesTypedSubscriptionAndNDJSON(t *testing.T) { + var out bytes.Buffer + trades := make(chan *ws.TradeEvent, 1) + event := &ws.TradeEvent{EventType: "trade", Symbol: "BTCUSD", Price: "100", Quantity: "0.5"} + trades <- event + fake := &streamPublicFake{trades: trades} + owner := &streamCloser{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + writer := &cancelOnWrite{Writer: &out, cancel: cancel} + root := newTestRootCommand(writer, writer) + root.SetContext(ctx) + root.AddCommand(NewStreamCommandWithFactories(func(_ context.Context, options GlobalOptions) (PublicStreamClient, io.Closer, error) { + if options.Environment != "sandbox" || options.Profile != "trader" { + t.Fatalf("options = %#v", options) + } + return fake, owner, nil + }, nil)) + root.SetArgs([]string{"--environment", "sandbox", "--profile", "trader", "stream", "trades", " btcusd "}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.tradesSymbol != "btcusd" { + t.Fatalf("trades symbol = %q", fake.tradesSymbol) + } + if !owner.closed { + t.Fatal("stream owner was not closed") + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 1 { + t.Fatalf("output lines = %q", out.String()) + } + var got ws.TradeEvent + if err := json.Unmarshal([]byte(lines[0]), &got); err != nil { + t.Fatalf("output = %q: %v", out.String(), err) + } + if got.Symbol != event.Symbol || got.Price != event.Price { + t.Fatalf("event = %#v", got) + } +} + +func TestStreamDepthMapsPartialDepthOptions(t *testing.T) { + var out bytes.Buffer + partial := make(chan *ws.OrderBookSnapshot) + fake := &streamPublicFake{partialDepth: partial} + root := newTestRootCommand(&out, &out) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + root.AddCommand(NewStreamCommandWithFactory(func(context.Context, GlobalOptions) (PublicStreamClient, io.Closer, error) { + return fake, nil, nil + })) + root.SetArgs([]string{"stream", "depth", "ethusd", "--levels", "20", "--interval", "100ms"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if fake.partialSym != "ethusd" || fake.partialOpts.Levels != ws.DepthLevel20 || fake.partialOpts.Interval != 100*time.Millisecond { + t.Fatalf("partial subscription = (%q, %#v)", fake.partialSym, fake.partialOpts) + } +} + +func TestStreamCancellationClosesOwnerWithoutError(t *testing.T) { + var out bytes.Buffer + ctx, cancel := context.WithCancel(context.Background()) + cancel() + trades := make(chan *ws.TradeEvent) + fake := &streamPublicFake{trades: trades} + owner := &streamCloser{} + root := newTestRootCommand(&out, &out) + root.SetContext(ctx) + root.AddCommand(NewStreamCommandWithFactory(func(context.Context, GlobalOptions) (PublicStreamClient, io.Closer, error) { + return fake, owner, nil + })) + root.SetArgs([]string{"stream", "trades", "BTCUSD"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !owner.closed { + t.Fatal("stream owner was not closed after cancellation") + } +} + +func TestStreamPrivateCommandsMapScopeAndInterval(t *testing.T) { + tests := []struct { + name string + args []string + check func(*streamPrivateFake) bool + }{ + {name: "orders", args: []string{"stream", "orders", "--scope", "session"}, check: func(fake *streamPrivateFake) bool { + return fake.orderScope == ws.ScopeSession + }}, + {name: "balances", args: []string{"stream", "balances", "--interval", "1s"}, check: func(fake *streamPrivateFake) bool { + return fake.balanceOpts.Interval == time.Second + }}, + {name: "positions", args: []string{"stream", "positions", "--interval", "1s"}, check: func(fake *streamPrivateFake) bool { + return fake.positionOpts.Interval == time.Second + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + fake := &streamPrivateFake{ + orders: make(chan *ws.OrderEvent), balances: make(chan *ws.BalanceUpdate), positions: make(chan *ws.PositionReport), + } + close(fake.orders) + close(fake.balances) + close(fake.positions) + root := newTestRootCommand(&out, &out) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root.SetContext(ctx) + root.AddCommand(NewStreamCommandWithFactories(nil, func(context.Context, GlobalOptions) (PrivateStreamClient, io.Closer, error) { + return fake, nil, nil + })) + root.SetArgs(test.args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !test.check(fake) { + t.Fatalf("private subscription = %#v", fake) + } + }) + } +} + +func TestConsumeStreamClosedChannelReturnsError(t *testing.T) { + events := make(chan *ws.TradeEvent) + close(events) + if err := consumeStream(context.Background(), io.Discard, events); err == nil { + t.Fatal("consumeStream() error = nil, want unexpected closure error") + } +} + +func TestConsumeStreamCancellationIsClean(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + events := make(chan *ws.TradeEvent) + if err := consumeStream(ctx, io.Discard, events); err != nil { + t.Fatalf("consumeStream() error = %v, want nil after cancellation", err) + } +} + +func TestConsumeStreamPropagatesSDKTerminalError(t *testing.T) { + terminalErr := errors.New("socket failed permanently") + events := make(chan *ws.TradeEvent) + connectionEvents := make(chan ws.ConnectionEvent, 1) + connectionEvents <- ws.ConnectionEvent{State: ws.StateDisconnected, Err: terminalErr} + err := consumeStream(context.Background(), io.Discard, events, connectionEvents) + if !errors.Is(err, terminalErr) { + t.Fatalf("consumeStream() error = %v, want terminal error %v", err, terminalErr) + } +} + +func TestConsumeStreamTreatsClosedLifecycleChannelAsError(t *testing.T) { + events := make(chan *ws.TradeEvent) + connectionEvents := make(chan ws.ConnectionEvent) + close(connectionEvents) + if err := consumeStream(context.Background(), io.Discard, events, connectionEvents); err == nil { + t.Fatal("consumeStream() error = nil, want lifecycle closure error") + } +} diff --git a/packages/gemini-markets-cli/internal/cli/test_helpers_test.go b/packages/gemini-markets-cli/internal/cli/test_helpers_test.go new file mode 100644 index 00000000..02b51720 --- /dev/null +++ b/packages/gemini-markets-cli/internal/cli/test_helpers_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "io" + + "github.com/spf13/cobra" +) + +// newTestRootCommand keeps command-injection tests focused on the command +// under test. Production roots register the complete command tree, so an +// injected command with the same name must replace that default for tests. +func newTestRootCommand(stdout, stderr io.Writer) *cobra.Command { + root := NewRootCommand(stdout, stderr) + root.RemoveCommand(root.Commands()...) + return root +} diff --git a/packages/gemini-markets-cli/internal/credentials/credentials.go b/packages/gemini-markets-cli/internal/credentials/credentials.go new file mode 100644 index 00000000..9672c569 --- /dev/null +++ b/packages/gemini-markets-cli/internal/credentials/credentials.go @@ -0,0 +1,195 @@ +// Package credentials loads CLI credentials from explicit values, the +// environment, and the operating-system keyring. +package credentials + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// ErrNotFound is returned by a Keyring when a profile has no saved values. +var ErrNotFound = errors.New("credentials not found") + +// Credentials contains all supported Gemini authentication material. Values +// are deliberately plain strings here; the SDK auth package owns signing and +// token validation once a session is created. +type Credentials struct { + APIKey string `json:"api_key,omitempty"` + APISecret string `json:"api_secret,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + OAuthClientID string `json:"oauth_client_id,omitempty"` + OAuthClientSecret string `json:"oauth_client_secret,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// Empty reports whether no credential material is configured. +func (c Credentials) Empty() bool { + return c.APIKey == "" && c.APISecret == "" && c.AccessToken == "" && + c.RefreshToken == "" && c.OAuthClientID == "" && c.OAuthClientSecret == "" +} + +// normalized makes precedence deterministic when values came from different +// sources and avoids carrying accidental whitespace into SDK auth options. +func (c Credentials) normalized() Credentials { + c.APIKey = strings.TrimSpace(c.APIKey) + c.APISecret = strings.TrimSpace(c.APISecret) + c.AccessToken = strings.TrimSpace(c.AccessToken) + c.RefreshToken = strings.TrimSpace(c.RefreshToken) + c.OAuthClientID = strings.TrimSpace(c.OAuthClientID) + c.OAuthClientSecret = strings.TrimSpace(c.OAuthClientSecret) + return c +} + +// Keyring is the persistence seam used by the CLI. Implementations may wrap +// an operating-system keychain, a test double, or FileKeyring below. The +// default implementation never falls back to FileKeyring. +type Keyring interface { + Get(ctx context.Context, profile string) (Credentials, error) + Set(ctx context.Context, profile string, value Credentials) error + Delete(ctx context.Context, profile string) error +} + +// Env is the small environment lookup seam used by Loader and tests. +type Env interface { + LookupEnv(key string) (string, bool) +} + +type osEnv struct{} + +func (osEnv) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) } + +// LoadOptions controls credential source precedence. A complete credential +// family is selected from the highest-priority source that provides one: +// Explicit, environment, then keyring. Fields are never merged between +// sources because doing so can pair an API key or OAuth token with a secret +// from a different owner. +type LoadOptions struct { + Profile string + Env Env + Keyring Keyring + Explicit Credentials +} + +// Origin identifies the credential source selected by LoadWithOrigin. +type Origin string + +const ( + OriginNone Origin = "none" + OriginExplicit Origin = "explicit" + OriginEnvironment Origin = "environment" + OriginKeyring Origin = "keyring" +) + +// Load resolves credentials without requiring authentication. This permits +// public market-data commands to use the same session factory as private +// commands. A missing keyring profile is ignored. If the default OS keyring +// is unavailable, environment and explicit credentials remain usable; other +// errors from an explicitly supplied keyring are returned to the caller. +func Load(ctx context.Context, opts LoadOptions) (Credentials, error) { + value, _, err := LoadWithOrigin(ctx, opts) + return value, err +} + +// LoadWithOrigin resolves credentials using Load's precedence rules and also +// reports the selected source. Callers use the origin to avoid persisting +// refreshed environment credentials into a keyring without user intent. +func LoadWithOrigin(ctx context.Context, opts LoadOptions) (Credentials, Origin, error) { + if ctx == nil { + ctx = context.Background() + } + profile := strings.TrimSpace(opts.Profile) + if profile == "" { + profile = "default" + } + + env := opts.Env + if env == nil { + env = osEnv{} + } + if resolved, ok := completeFamily(opts.Explicit); ok { + return resolved, OriginExplicit, nil + } + if resolved, ok := completeFamily(credentialsFromEnv(env)); ok { + return resolved, OriginEnvironment, nil + } + + keyring := opts.Keyring + internalDefaultKeyring := keyring == nil + if internalDefaultKeyring { + keyring = defaultKeyring() + } else if _, ok := keyring.(*lazyOSKeyring); ok { + internalDefaultKeyring = true + } + if keyring == nil { + return Credentials{}, OriginNone, nil + } + value, err := keyring.Get(ctx, profile) + if err != nil && !errors.Is(err, ErrNotFound) && !(internalDefaultKeyring && errors.Is(err, ErrUnavailable)) { + return Credentials{}, OriginNone, fmt.Errorf("load credentials from keyring: %w", err) + } + if err == nil { + if resolved, ok := completeFamily(value); ok { + return resolved, OriginKeyring, nil + } + } + return Credentials{}, OriginNone, nil +} + +// completeFamily returns one source's complete authentication family. OAuth +// token material wins within a source, matching the SDK's bearer precedence; +// OAuth client metadata is retained only when it came from that same source. +func completeFamily(value Credentials) (Credentials, bool) { + value = value.normalized() + if value.AccessToken != "" || value.RefreshToken != "" { + return Credentials{ + AccessToken: value.AccessToken, + RefreshToken: value.RefreshToken, + OAuthClientID: value.OAuthClientID, + OAuthClientSecret: value.OAuthClientSecret, + ExpiresAt: value.ExpiresAt, + }, true + } + if value.APIKey != "" && value.APISecret != "" { + return Credentials{APIKey: value.APIKey, APISecret: value.APISecret}, true + } + return Credentials{}, false +} + +func credentialsFromEnv(env Env) Credentials { + return Credentials{ + APIKey: lookup(env, "GEMINI_API_KEY"), + APISecret: lookup(env, "GEMINI_API_SECRET"), + AccessToken: firstLookup(env, "GEMINI_ACCESS_TOKEN", "GEMINI_BEARER_TOKEN"), + RefreshToken: lookup(env, "GEMINI_REFRESH_TOKEN"), + OAuthClientID: lookup(env, "GEMINI_OAUTH_CLIENT_ID"), + OAuthClientSecret: lookup(env, "GEMINI_OAUTH_CLIENT_SECRET"), + } +} + +// NewDefaultKeyring returns the lazy operating-system keyring used by normal +// CLI sessions. The lazy wrapper keeps public and headless commands usable +// when no desktop keychain is available, while still giving authenticated +// sessions one stable persistence seam for OAuth token rotation. +func NewDefaultKeyring() Keyring { return defaultKeyring() } + +func lookup(env Env, key string) string { + value, ok := env.LookupEnv(key) + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func firstLookup(env Env, keys ...string) string { + for _, key := range keys { + if value := lookup(env, key); value != "" { + return value + } + } + return "" +} diff --git a/packages/gemini-markets-cli/internal/credentials/credentials_test.go b/packages/gemini-markets-cli/internal/credentials/credentials_test.go new file mode 100644 index 00000000..7a0db45b --- /dev/null +++ b/packages/gemini-markets-cli/internal/credentials/credentials_test.go @@ -0,0 +1,190 @@ +package credentials + +import ( + "context" + "errors" + "testing" +) + +type mapEnv map[string]string + +func (e mapEnv) LookupEnv(key string) (string, bool) { + value, ok := e[key] + return value, ok +} + +type testKeyring struct { + value Credentials + err error +} + +func (k testKeyring) Get(context.Context, string) (Credentials, error) { return k.value, k.err } +func (testKeyring) Set(context.Context, string, Credentials) error { return nil } +func (testKeyring) Delete(context.Context, string) error { return nil } + +func TestLoadPrecedence(t *testing.T) { + got, err := Load(context.Background(), LoadOptions{ + Profile: "work", + Keyring: testKeyring{value: Credentials{ + APIKey: "keyring-key", APISecret: "keyring-secret", AccessToken: "keyring-token", + }}, + Env: mapEnv{ + "GEMINI_API_KEY": " env-key ", + "GEMINI_ACCESS_TOKEN": "env-token", + }, + Explicit: Credentials{APISecret: "explicit-secret"}, + }) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + want := Credentials{AccessToken: "env-token"} + if got != want { + t.Fatalf("Load() = %#v, want %#v", got, want) + } +} + +func TestLoadWithOriginReportsSelectedCredentialSource(t *testing.T) { + value, origin, err := LoadWithOrigin(context.Background(), LoadOptions{ + Env: mapEnv{"GEMINI_ACCESS_TOKEN": "env-token"}, + Keyring: testKeyring{value: Credentials{APIKey: "stored-key", APISecret: "stored-secret"}}, + }) + if err != nil { + t.Fatalf("LoadWithOrigin() error = %v", err) + } + if origin != OriginEnvironment || value.AccessToken != "env-token" { + t.Fatalf("LoadWithOrigin() = %#v, %q; want environment token", value, origin) + } + + value, origin, err = LoadWithOrigin(context.Background(), LoadOptions{ + Env: mapEnv{}, + Keyring: testKeyring{value: Credentials{APIKey: "stored-key", APISecret: "stored-secret"}}, + }) + if err != nil { + t.Fatalf("LoadWithOrigin() keyring error = %v", err) + } + if origin != OriginKeyring || value.APIKey != "stored-key" { + t.Fatalf("LoadWithOrigin() = %#v, %q; want keyring credentials", value, origin) + } +} + +func TestLoadTreatsNewDefaultKeyringAsOptional(t *testing.T) { + value, origin, err := LoadWithOrigin(context.Background(), LoadOptions{ + Env: mapEnv{}, + Keyring: &lazyOSKeyring{err: ErrUnavailable}, + }) + if err != nil || !value.Empty() || origin != OriginNone { + t.Fatalf("LoadWithOrigin() = %#v, %q, %v; want empty optional keyring result", value, origin, err) + } +} + +func TestLoadSelectsCompleteFamilyWithoutMergingSources(t *testing.T) { + tests := []struct { + name string + explicit Credentials + env mapEnv + keyring Credentials + want Credentials + }{ + { + name: "explicit HMAC wins over lower OAuth", + explicit: Credentials{APIKey: "explicit-key", APISecret: "explicit-secret"}, + env: mapEnv{"GEMINI_ACCESS_TOKEN": "env-token", "GEMINI_REFRESH_TOKEN": "env-refresh", "GEMINI_OAUTH_CLIENT_ID": "env-client"}, + keyring: Credentials{APIKey: "stored-key", APISecret: "stored-secret"}, + want: Credentials{APIKey: "explicit-key", APISecret: "explicit-secret"}, + }, + { + name: "incomplete explicit HMAC does not borrow secret", + explicit: Credentials{APIKey: "explicit-key"}, + env: mapEnv{"GEMINI_API_KEY": "env-key", "GEMINI_API_SECRET": "env-secret"}, + want: Credentials{APIKey: "env-key", APISecret: "env-secret"}, + }, + { + name: "environment OAuth keeps same-source metadata", + env: mapEnv{"GEMINI_ACCESS_TOKEN": "env-token", "GEMINI_REFRESH_TOKEN": "env-refresh", "GEMINI_OAUTH_CLIENT_ID": "env-client", "GEMINI_OAUTH_CLIENT_SECRET": "env-secret"}, + keyring: Credentials{OAuthClientID: "stored-client", OAuthClientSecret: "stored-secret"}, + want: Credentials{AccessToken: "env-token", RefreshToken: "env-refresh", OAuthClientID: "env-client", OAuthClientSecret: "env-secret"}, + }, + { + name: "higher OAuth source does not borrow lower refresh metadata", + explicit: Credentials{AccessToken: "explicit-token"}, + env: mapEnv{"GEMINI_REFRESH_TOKEN": "env-refresh", "GEMINI_OAUTH_CLIENT_ID": "env-client"}, + want: Credentials{AccessToken: "explicit-token"}, + }, + { + name: "keyring fallback", + keyring: Credentials{APIKey: "stored-key", APISecret: "stored-secret"}, + want: Credentials{APIKey: "stored-key", APISecret: "stored-secret"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := Load(context.Background(), LoadOptions{ + Explicit: test.explicit, + Env: test.env, + Keyring: testKeyring{value: test.keyring}, + }) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got != test.want { + t.Fatalf("Load() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestLoadMissingKeyringIsAllowed(t *testing.T) { + got, err := Load(context.Background(), LoadOptions{ + Keyring: testKeyring{err: ErrNotFound}, + Env: mapEnv{}, + }) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !got.Empty() { + t.Fatalf("Load() = %#v, want empty credentials", got) + } +} + +func TestLoadKeyringErrorsAreReturned(t *testing.T) { + keyringErr := errors.New("keychain unavailable") + _, err := Load(context.Background(), LoadOptions{Keyring: testKeyring{err: keyringErr}, Env: mapEnv{}}) + if !errors.Is(err, keyringErr) { + t.Fatalf("Load() error = %v, want %v", err, keyringErr) + } +} + +func TestLoadExplicitUnavailableKeyringErrorIsReturned(t *testing.T) { + _, err := Load(context.Background(), LoadOptions{ + Keyring: testKeyring{err: ErrUnavailable}, + Env: mapEnv{}, + }) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("Load() error = %v, want ErrUnavailable", err) + } +} + +func TestFileKeyringRoundTrip(t *testing.T) { + path := t.TempDir() + "/credentials.json" + keyring, err := NewFileKeyring(path) + if err != nil { + t.Fatalf("NewFileKeyring() error = %v", err) + } + value := Credentials{APIKey: "key", APISecret: "secret", RefreshToken: "refresh"} + if err := keyring.Set(context.Background(), "", value); err != nil { + t.Fatalf("Set() error = %v", err) + } + got, err := keyring.Get(context.Background(), "default") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got != value { + t.Fatalf("Get() = %#v, want %#v", got, value) + } + if err := keyring.Delete(context.Background(), "default"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if _, err := keyring.Get(context.Background(), "default"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() after Delete() error = %v, want ErrNotFound", err) + } +} diff --git a/packages/gemini-markets-cli/internal/credentials/file_keyring.go b/packages/gemini-markets-cli/internal/credentials/file_keyring.go new file mode 100644 index 00000000..cb40cad7 --- /dev/null +++ b/packages/gemini-markets-cli/internal/credentials/file_keyring.go @@ -0,0 +1,150 @@ +package credentials + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// FileKeyring is a small persistence implementation for environments without +// an OS keychain. It writes a 0600 JSON file and uses an atomic rename. Users +// that need hardware-backed or desktop keychain protection can inject a +// Keyring implementation instead. +type FileKeyring struct { + path string + mu sync.Mutex +} + +// NewFileKeyring creates a file-backed keyring at path. It does not create or +// touch the path until Set is called. +func NewFileKeyring(path string) (*FileKeyring, error) { + if path == "" { + return nil, errors.New("keyring path is required") + } + return &FileKeyring{path: path}, nil +} + +type fileData struct { + Profiles map[string]Credentials `json:"profiles"` +} + +func (k *FileKeyring) Get(ctx context.Context, profile string) (Credentials, error) { + if err := contextErr(ctx); err != nil { + return Credentials{}, err + } + profile = normalizeProfile(profile) + k.mu.Lock() + defer k.mu.Unlock() + data, err := k.readLocked() + if err != nil { + return Credentials{}, err + } + value, ok := data.Profiles[profile] + if !ok { + return Credentials{}, ErrNotFound + } + return value.normalized(), nil +} + +func (k *FileKeyring) Set(ctx context.Context, profile string, value Credentials) error { + if err := contextErr(ctx); err != nil { + return err + } + profile = normalizeProfile(profile) + k.mu.Lock() + defer k.mu.Unlock() + data, err := k.readLocked() + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + if data.Profiles == nil { + data.Profiles = make(map[string]Credentials) + } + data.Profiles[profile] = value.normalized() + return k.writeLocked(data) +} + +func (k *FileKeyring) Delete(ctx context.Context, profile string) error { + if err := contextErr(ctx); err != nil { + return err + } + profile = normalizeProfile(profile) + k.mu.Lock() + defer k.mu.Unlock() + data, err := k.readLocked() + if err != nil { + return err + } + if _, ok := data.Profiles[profile]; !ok { + return ErrNotFound + } + delete(data.Profiles, profile) + return k.writeLocked(data) +} + +func (k *FileKeyring) readLocked() (fileData, error) { + contents, err := os.ReadFile(k.path) + if errors.Is(err, os.ErrNotExist) { + return fileData{}, ErrNotFound + } + if err != nil { + return fileData{}, fmt.Errorf("read keyring: %w", err) + } + var data fileData + if err := json.Unmarshal(contents, &data); err != nil { + return fileData{}, fmt.Errorf("decode keyring: %w", err) + } + if data.Profiles == nil { + data.Profiles = make(map[string]Credentials) + } + return data, nil +} + +func (k *FileKeyring) writeLocked(data fileData) error { + if err := os.MkdirAll(filepath.Dir(k.path), 0o700); err != nil { + return fmt.Errorf("create keyring directory: %w", err) + } + contents, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode keyring: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(k.path), ".gemini-keyring-*") + if err != nil { + return fmt.Errorf("create keyring temporary file: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("protect keyring temporary file: %w", err) + } + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return fmt.Errorf("write keyring: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close keyring temporary file: %w", err) + } + if err := os.Rename(temporaryPath, k.path); err != nil { + return fmt.Errorf("replace keyring: %w", err) + } + return nil +} + +func contextErr(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} + +func normalizeProfile(profile string) string { + if profile == "" { + return "default" + } + return profile +} diff --git a/packages/gemini-markets-cli/internal/credentials/os_keyring.go b/packages/gemini-markets-cli/internal/credentials/os_keyring.go new file mode 100644 index 00000000..23fec933 --- /dev/null +++ b/packages/gemini-markets-cli/internal/credentials/os_keyring.go @@ -0,0 +1,188 @@ +package credentials + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + + keyring "github.com/99designs/keyring" +) + +const ( + // ServiceName is the service name used for all CLI credentials in the + // operating system credential store. + ServiceName = "gemini-markets" +) + +// ErrUnavailable indicates that no supported operating-system credential +// store is available. Environment credentials remain usable in this case, +// but login cannot persist credentials until a keyring is available. +var ErrUnavailable = errors.New("operating-system keyring unavailable") + +// secureBackends deliberately excludes keyring's file and pass backends. The +// CLI must never silently downgrade to a plaintext or file-backed credential +// store when an OS keychain is unavailable. +var secureBackends = []keyring.BackendType{ + keyring.WinCredBackend, + keyring.KeychainBackend, + keyring.SecretServiceBackend, + keyring.KWalletBackend, + keyring.KeyCtlBackend, +} + +// OSKeyring stores credentials in a desktop or operating-system keyring. +// Values are encoded as JSON in one keyring item per profile. The backend is +// intentionally an internal seam so command tests can use an in-memory fake. +type OSKeyring struct { + backend keyring.Keyring + mu sync.Mutex +} + +// NewOSKeyring opens one of the supported operating-system credential stores. +// File and pass backends are not considered, so an unavailable OS keyring is +// returned as an error instead of causing a less secure fallback. +func NewOSKeyring() (*OSKeyring, error) { + backend, err := keyring.Open(keyring.Config{ + ServiceName: ServiceName, + AllowedBackends: append([]keyring.BackendType(nil), secureBackends...), + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return &OSKeyring{backend: backend}, nil +} + +// newOSKeyringWithBackend wraps a keyring implementation. It is kept +// unexported because production callers should use NewOSKeyring; package +// tests use it to exercise serialization without touching a user's keychain. +func newOSKeyringWithBackend(backend keyring.Keyring) (*OSKeyring, error) { + if backend == nil { + return nil, errors.New("keyring backend is required") + } + return &OSKeyring{backend: backend}, nil +} + +func (k *OSKeyring) Get(ctx context.Context, profile string) (Credentials, error) { + if err := contextErr(ctx); err != nil { + return Credentials{}, err + } + item, err := k.item(profile) + if err != nil { + return Credentials{}, err + } + k.mu.Lock() + defer k.mu.Unlock() + item, err = k.backend.Get(item.Key) + if errors.Is(err, keyring.ErrKeyNotFound) { + return Credentials{}, ErrNotFound + } + if err != nil { + return Credentials{}, fmt.Errorf("read OS keyring: %w", err) + } + var value Credentials + if err := json.Unmarshal(item.Data, &value); err != nil { + return Credentials{}, fmt.Errorf("decode OS keyring credentials: %w", err) + } + return value.normalized(), nil +} + +func (k *OSKeyring) Set(ctx context.Context, profile string, value Credentials) error { + if err := contextErr(ctx); err != nil { + return err + } + item, err := k.item(profile) + if err != nil { + return err + } + data, err := json.Marshal(value.normalized()) + if err != nil { + return fmt.Errorf("encode OS keyring credentials: %w", err) + } + item.Data = data + k.mu.Lock() + defer k.mu.Unlock() + if err := k.backend.Set(item); err != nil { + return fmt.Errorf("write OS keyring: %w", err) + } + return nil +} + +func (k *OSKeyring) Delete(ctx context.Context, profile string) error { + if err := contextErr(ctx); err != nil { + return err + } + item, err := k.item(profile) + if err != nil { + return err + } + k.mu.Lock() + defer k.mu.Unlock() + if err := k.backend.Remove(item.Key); err != nil { + if errors.Is(err, keyring.ErrKeyNotFound) { + return ErrNotFound + } + return fmt.Errorf("delete OS keyring credentials: %w", err) + } + return nil +} + +func (k *OSKeyring) item(profile string) (keyring.Item, error) { + if k == nil || k.backend == nil { + return keyring.Item{}, ErrUnavailable + } + return keyring.Item{ + Key: normalizeProfile(profile), + Label: "Gemini Markets CLI credentials", + Description: "Gemini Markets CLI credentials for the selected profile", + }, nil +} + +// lazyOSKeyring lets commands that can operate with environment credentials +// retain that behavior on headless systems. It still makes the OS keyring the +// default persistence path and never falls back to FileKeyring. +type lazyOSKeyring struct { + mu sync.Mutex + keyring *OSKeyring + err error +} + +func (k *lazyOSKeyring) open() (*OSKeyring, error) { + k.mu.Lock() + defer k.mu.Unlock() + if k.keyring != nil || k.err != nil { + return k.keyring, k.err + } + k.keyring, k.err = NewOSKeyring() + return k.keyring, k.err +} + +func (k *lazyOSKeyring) Get(ctx context.Context, profile string) (Credentials, error) { + backend, err := k.open() + if err != nil { + return Credentials{}, err + } + return backend.Get(ctx, profile) +} + +func (k *lazyOSKeyring) Set(ctx context.Context, profile string, value Credentials) error { + backend, err := k.open() + if err != nil { + return err + } + return backend.Set(ctx, profile, value) +} + +func (k *lazyOSKeyring) Delete(ctx context.Context, profile string) error { + backend, err := k.open() + if err != nil { + return err + } + return backend.Delete(ctx, profile) +} + +func defaultKeyring() Keyring { return &lazyOSKeyring{} } + +var _ Keyring = (*OSKeyring)(nil) +var _ Keyring = (*lazyOSKeyring)(nil) diff --git a/packages/gemini-markets-cli/internal/credentials/os_keyring_test.go b/packages/gemini-markets-cli/internal/credentials/os_keyring_test.go new file mode 100644 index 00000000..1e37b075 --- /dev/null +++ b/packages/gemini-markets-cli/internal/credentials/os_keyring_test.go @@ -0,0 +1,138 @@ +package credentials + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + keyring "github.com/99designs/keyring" +) + +type memoryOSKeyring struct { + items map[string]keyring.Item +} + +func (k *memoryOSKeyring) Get(name string) (keyring.Item, error) { + item, ok := k.items[name] + if !ok { + return keyring.Item{}, keyring.ErrKeyNotFound + } + return item, nil +} + +func (k *memoryOSKeyring) GetMetadata(name string) (keyring.Metadata, error) { + item, err := k.Get(name) + if err != nil { + return keyring.Metadata{}, err + } + item.Data = nil + return keyring.Metadata{Item: &item}, nil +} + +func (k *memoryOSKeyring) Set(item keyring.Item) error { + if k.items == nil { + k.items = make(map[string]keyring.Item) + } + k.items[item.Key] = item + return nil +} + +func (k *memoryOSKeyring) Remove(name string) error { + if _, ok := k.items[name]; !ok { + return keyring.ErrKeyNotFound + } + delete(k.items, name) + return nil +} + +func (k *memoryOSKeyring) Keys() ([]string, error) { + keys := make([]string, 0, len(k.items)) + for key := range k.items { + keys = append(keys, key) + } + return keys, nil +} + +func TestOSKeyringRoundTrip(t *testing.T) { + backend := &memoryOSKeyring{} + keyring, err := newOSKeyringWithBackend(backend) + if err != nil { + t.Fatalf("newOSKeyringWithBackend() error = %v", err) + } + want := Credentials{ + APIKey: " api-key ", APISecret: "api-secret", AccessToken: "access-token", + RefreshToken: "refresh-token", OAuthClientID: "client-id", OAuthClientSecret: "client-secret", + ExpiresAt: time.Date(2026, time.August, 29, 12, 0, 0, 0, time.UTC), + } + if err := keyring.Set(context.Background(), "", want); err != nil { + t.Fatalf("Set() error = %v", err) + } + got, err := keyring.Get(context.Background(), "default") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + want.APIKey = "api-key" + if got != want { + t.Fatalf("Get() = %#v, want %#v", got, want) + } + if string(backend.items["default"].Data) == "" { + t.Fatal("Set() stored no data") + } + if err := keyring.Delete(context.Background(), "default"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if _, err := keyring.Get(context.Background(), "default"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() after Delete() error = %v, want ErrNotFound", err) + } +} + +func TestOSKeyringDoesNotFallbackToFileBackend(t *testing.T) { + for _, backend := range secureBackends { + if backend == keyring.FileBackend || backend == keyring.PassBackend { + t.Fatalf("secure backend list includes file-backed backend %q", backend) + } + } +} + +func TestOSKeyringHonorsCanceledContext(t *testing.T) { + keyring, err := newOSKeyringWithBackend(&memoryOSKeyring{}) + if err != nil { + t.Fatalf("newOSKeyringWithBackend() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := keyring.Set(ctx, "default", Credentials{APIKey: "secret"}); !errors.Is(err, context.Canceled) { + t.Fatalf("Set() error = %v, want context.Canceled", err) + } +} + +func TestOSKeyringSerializesConcurrentCredentialRotation(t *testing.T) { + backend := &memoryOSKeyring{} + keyring, err := newOSKeyringWithBackend(backend) + if err != nil { + t.Fatalf("newOSKeyringWithBackend() error = %v", err) + } + + const writers = 32 + var wg sync.WaitGroup + wg.Add(writers) + for i := 0; i < writers; i++ { + go func() { + defer wg.Done() + value := Credentials{AccessToken: "access", RefreshToken: "refresh", OAuthClientID: "client"} + if err := keyring.Set(context.Background(), "default", value); err != nil { + t.Errorf("Set() error = %v", err) + } + }() + } + wg.Wait() + got, err := keyring.Get(context.Background(), "default") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.AccessToken != "access" || got.RefreshToken != "refresh" || got.OAuthClientID != "client" { + t.Fatalf("concurrent rotation produced incomplete credentials: %#v", got) + } +} diff --git a/packages/gemini-markets-cli/internal/output/output.go b/packages/gemini-markets-cli/internal/output/output.go new file mode 100644 index 00000000..6e9132fe --- /dev/null +++ b/packages/gemini-markets-cli/internal/output/output.go @@ -0,0 +1,122 @@ +// Package output contains the deliberately small rendering seam shared by +// CLI commands. Commands can keep their service results typed and choose JSON +// or table output only at the boundary. +package output + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// Format is a supported CLI output format. +type Format string + +const ( + JSON Format = "json" + Table Format = "table" +) + +var ( + ErrInvalidFormat = errors.New("invalid output format") + ErrInvalidTable = errors.New("invalid table output") +) + +// ParseFormat validates a user-provided output format. An empty value uses the +// human-readable table format used by the root command. +func ParseFormat(value string) (Format, error) { + switch Format(strings.ToLower(strings.TrimSpace(value))) { + case "", Table: + return Table, nil + case JSON: + return JSON, nil + default: + return "", fmt.Errorf("%w %q (want json or table)", ErrInvalidFormat, value) + } +} + +// TableData is the format-independent representation used for table output. +type TableData struct { + Headers []string + Rows [][]string +} + +// Write renders value using format. JSON values are indented for terminal +// readability and always end in a newline. Table values must be TableData (or +// *TableData), which keeps command-specific row formatting explicit. +func Write(w io.Writer, value any, format Format) error { + if w == nil { + return errors.New("output writer is nil") + } + switch format { + case JSON: + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(value) + case Table: + switch table := value.(type) { + case TableData: + return RenderTable(w, table) + case *TableData: + if table == nil { + return fmt.Errorf("%w: table is nil", ErrInvalidTable) + } + return RenderTable(w, *table) + default: + return fmt.Errorf("%w: got %T, want output.TableData", ErrInvalidTable, value) + } + default: + return fmt.Errorf("%w %q", ErrInvalidFormat, format) + } +} + +// RenderTable writes a compact, aligned table. Rows shorter than the header +// are padded; extra cells are rejected because silently hiding a value is a +// poor terminal failure mode. +func RenderTable(w io.Writer, table TableData) error { + if w == nil { + return errors.New("output writer is nil") + } + if len(table.Headers) == 0 { + return fmt.Errorf("%w: at least one header is required", ErrInvalidTable) + } + for i, row := range table.Rows { + if len(row) > len(table.Headers) { + return fmt.Errorf("%w: row %d has %d cells, want at most %d", ErrInvalidTable, i, len(row), len(table.Headers)) + } + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, strings.Join(table.Headers, "\t")); err != nil { + return err + } + for _, row := range table.Rows { + cells := make([]string, len(table.Headers)) + copy(cells, row) + if _, err := fmt.Fprintln(tw, strings.Join(cells, "\t")); err != nil { + return err + } + } + return tw.Flush() +} + +// WriteError emits a stable, secret-free error envelope. The underlying +// error is intentionally represented only by its message; commands should +// wrap errors with operation context before they reach this boundary. +func WriteError(w io.Writer, err error, format Format) error { + if err == nil { + err = errors.New("unknown error") + } + if format == JSON { + return Write(w, struct { + Error string `json:"error"` + }{Error: err.Error()}, JSON) + } + if format != Table { + return fmt.Errorf("%w %q", ErrInvalidFormat, format) + } + _, writeErr := fmt.Fprintf(w, "Error: %s\n", err) + return writeErr +} diff --git a/packages/gemini-markets-cli/internal/output/output_test.go b/packages/gemini-markets-cli/internal/output/output_test.go new file mode 100644 index 00000000..49076980 --- /dev/null +++ b/packages/gemini-markets-cli/internal/output/output_test.go @@ -0,0 +1,53 @@ +package output + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestWriteJSON(t *testing.T) { + var out bytes.Buffer + if err := Write(&out, map[string]string{"status": "ok"}, JSON); err != nil { + t.Fatalf("Write() error = %v", err) + } + if got, want := out.String(), "{\n \"status\": \"ok\"\n}\n"; got != want { + t.Fatalf("Write() = %q, want %q", got, want) + } +} + +func TestWriteTableAndError(t *testing.T) { + var out bytes.Buffer + table := TableData{Headers: []string{"SYMBOL", "PRICE"}, Rows: [][]string{{"BTCUSD", "100"}}} + if err := Write(&out, table, Table); err != nil { + t.Fatalf("Write() error = %v", err) + } + if !strings.Contains(out.String(), "SYMBOL PRICE") || !strings.Contains(out.String(), "BTCUSD 100") { + t.Fatalf("Write() = %q, want aligned table", out.String()) + } + out.Reset() + if err := WriteError(&out, errors.New("request failed"), JSON); err != nil { + t.Fatalf("WriteError() error = %v", err) + } + if got, want := out.String(), "{\n \"error\": \"request failed\"\n}\n"; got != want { + t.Fatalf("WriteError() = %q, want %q", got, want) + } +} + +func TestParseFormat(t *testing.T) { + for _, test := range []struct { + input string + want Format + }{ + {"", Table}, {" TABLE ", Table}, {"Json", JSON}, + } { + got, err := ParseFormat(test.input) + if err != nil || got != test.want { + t.Fatalf("ParseFormat(%q) = %q, %v; want %q", test.input, got, err, test.want) + } + } + if _, err := ParseFormat("yaml"); !errors.Is(err, ErrInvalidFormat) { + t.Fatalf("ParseFormat(yaml) error = %v, want ErrInvalidFormat", err) + } +} diff --git a/packages/gemini-markets-cli/internal/session/session.go b/packages/gemini-markets-cli/internal/session/session.go new file mode 100644 index 00000000..86b1d902 --- /dev/null +++ b/packages/gemini-markets-cli/internal/session/session.go @@ -0,0 +1,237 @@ +// Package session builds the official SDK client used by CLI commands. +// Commands should call services on Session.Client directly; this package does +// not duplicate REST or WebSocket protocol logic. +package session + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/credentials" + gemini "github.com/gemini/developer-platform/packages/sdk-go" + "github.com/gemini/developer-platform/packages/sdk-go/auth" + geminioauth "github.com/gemini/developer-platform/packages/sdk-go/oauth" + "github.com/gemini/developer-platform/packages/sdk-go/websocket" + "github.com/gemini/developer-platform/packages/sdk-go/websocket/gorilla" +) + +// AuthMode describes the SDK authentication option selected by New. +type AuthMode string + +const ( + AuthNone AuthMode = "none" + AuthHMAC AuthMode = "hmac" + AuthBearer AuthMode = "bearer" + AuthOAuth AuthMode = "oauth" +) + +var ErrOAuthClientIDRequired = errors.New("OAuth client ID is required when a refresh token is configured") + +// OAuthSourceFactory allows applications and tests to replace construction of +// the SDK's refreshable OAuth token source while retaining the same session +// wiring. A nil factory uses gemini/oauth.NewTokenSource. +type OAuthSourceFactory func(environment gemini.Environment, value credentials.Credentials, httpClient *http.Client) (auth.TokenSource, error) + +// Config controls client construction. +type Config struct { + Environment gemini.Environment + Credentials credentials.Credentials + + // PrivateWebSockets selects the time-based HMAC strategy required by + // authenticated WebSocket handshakes. REST-only sessions use the strict + // increasing nonce strategy from WithAPIKey. + PrivateWebSockets bool + + // CredentialStore and CredentialProfile enable atomic persistence of OAuth + // token rotations. The store should be an OS-backed keyring in production; + // tests may inject a narrow in-memory implementation. + CredentialStore credentials.Keyring + CredentialProfile string + + // TokenSource, when set, takes precedence over credential fields and is + // passed to gemini.WithTokenSource unchanged. + TokenSource auth.TokenSource + + // HTTPClient is used by the SDK REST transport. OAuthHTTPClient is used for + // refresh requests; when nil, HTTPClient is reused. + HTTPClient *http.Client + OAuthHTTPClient *http.Client + + // RESTURL and WSURL are optional endpoint overrides, primarily useful for + // controlled deployments and tests. Production and sandbox defaults come + // from the official SDK environment option. + RESTURL string + WSURL string + + // EnableWebSockets opts into the SDK's Gorilla adapter. Keeping this false + // by default avoids opening or configuring stream transport for REST-only + // commands. + EnableWebSockets bool + Dialer websocket.Dialer + + OAuthSourceFactory OAuthSourceFactory +} + +// Session owns an SDK client and the metadata selected to create it. +type Session struct { + Client *gemini.Client + Environment gemini.Environment + Credentials credentials.Credentials + AuthMode AuthMode +} + +// New creates an official SDK client for the requested environment and auth +// material. It performs configuration validation before returning so command +// failures happen before a network request is attempted. +func New(config Config) (*Session, error) { + environment := normalizeEnvironment(config.Environment) + options := []gemini.Option{gemini.WithEnvironment(environment)} + if config.RESTURL != "" { + options = append(options, gemini.WithCustomRESTURL(config.RESTURL)) + } + if config.WSURL != "" { + options = append(options, gemini.WithCustomWSURL(config.WSURL)) + } + if config.HTTPClient != nil { + options = append(options, gemini.WithHTTPClient(config.HTTPClient)) + } + + mode, authOption, err := authOptionFor(config, environment) + if err != nil { + return nil, err + } + if authOption != nil { + options = append(options, authOption) + } + + if config.EnableWebSockets || config.PrivateWebSockets || config.Dialer != nil { + dialer := config.Dialer + if dialer == nil { + dialer = gorilla.NewDialer() + } + options = append(options, gemini.WithWebSocketDialer(dialer)) + } + + client, err := gemini.NewClientWithError(options...) + if err != nil { + return nil, fmt.Errorf("create Gemini SDK client: %w", err) + } + return &Session{ + Client: client, + Environment: environment, + Credentials: config.Credentials, + AuthMode: mode, + }, nil +} + +// Close releases SDK-owned transports and stream connections. +func (s *Session) Close() error { + if s == nil || s.Client == nil { + return nil + } + return s.Client.Close() +} + +func authOptionFor(config Config, environment gemini.Environment) (AuthMode, gemini.Option, error) { + mode, strategy, err := authStrategyFor(config, environment) + if err != nil { + return AuthNone, nil, err + } + if mode == AuthHMAC { + if config.PrivateWebSockets { + return mode, gemini.WithTimeBasedAPIKey(config.Credentials.APIKey, config.Credentials.APISecret), nil + } + return mode, gemini.WithAPIKey(config.Credentials.APIKey, config.Credentials.APISecret), nil + } + if strategy == nil { + return mode, nil, nil + } + return mode, gemini.WithAuth(strategy), nil +} + +func authStrategyFor(config Config, environment gemini.Environment) (AuthMode, auth.Strategy, error) { + if config.TokenSource != nil { + return AuthOAuth, auth.NewBearerWithSource(config.TokenSource), nil + } + + value := config.Credentials + if value.AccessToken != "" || value.RefreshToken != "" { + if value.RefreshToken != "" { + if strings.TrimSpace(value.OAuthClientID) == "" { + return AuthNone, nil, ErrOAuthClientIDRequired + } + source, err := newOAuthSource(config, environment) + if err != nil { + return AuthNone, nil, fmt.Errorf("create OAuth token source: %w", err) + } + return AuthOAuth, auth.NewBearerWithSource(source), nil + } + return AuthBearer, auth.NewBearer(auth.BearerToken(value.AccessToken)), nil + } + if value.APIKey != "" || value.APISecret != "" { + if config.PrivateWebSockets { + return AuthHMAC, auth.NewTimeBasedHMAC(auth.APIKey(value.APIKey), auth.APISecret(value.APISecret)), nil + } + return AuthHMAC, auth.NewHMAC(auth.APIKey(value.APIKey), auth.APISecret(value.APISecret)), nil + } + return AuthNone, nil, nil +} + +func newOAuthSource(config Config, environment gemini.Environment) (auth.TokenSource, error) { + if config.OAuthSourceFactory != nil { + return config.OAuthSourceFactory(environment, config.Credentials, oauthHTTPClient(config)) + } + endpoints, ok := gemini.EndpointsFor(environment) + if !ok { + return nil, fmt.Errorf("unknown Gemini environment %q", environment) + } + initial := geminioauth.Token{ + AccessToken: config.Credentials.AccessToken, + RefreshToken: config.Credentials.RefreshToken, + TokenType: "Bearer", + ExpiresAt: config.Credentials.ExpiresAt, + } + oauthConfig := geminioauth.Config{ + ClientID: config.Credentials.OAuthClientID, + ClientSecret: config.Credentials.OAuthClientSecret, + Endpoint: geminioauth.Endpoint{ + AuthURL: endpoints.OAuthAuthorization, + TokenURL: endpoints.OAuthToken, + }, + HTTPClient: oauthHTTPClient(config), + } + var sourceOptions []geminioauth.SourceOption + profile := strings.TrimSpace(config.CredentialProfile) + if config.CredentialStore != nil && profile != "" { + stored := config.Credentials + sourceOptions = append(sourceOptions, geminioauth.WithTokenUpdate(func(ctx context.Context, token geminioauth.Token) error { + updated := stored + updated.AccessToken = token.AccessToken + updated.RefreshToken = token.RefreshToken + updated.ExpiresAt = token.ExpiresAt + if err := config.CredentialStore.Set(ctx, profile, updated); err != nil { + return fmt.Errorf("persist refreshed OAuth credentials: %w", err) + } + stored = updated + return nil + })) + } + return geminioauth.NewTokenSource(oauthConfig, initial, sourceOptions...) +} + +func oauthHTTPClient(config Config) *http.Client { + if config.OAuthHTTPClient != nil { + return config.OAuthHTTPClient + } + return config.HTTPClient +} + +func normalizeEnvironment(environment gemini.Environment) gemini.Environment { + if strings.TrimSpace(string(environment)) == "" { + return gemini.Production + } + return gemini.Environment(strings.ToLower(strings.TrimSpace(string(environment)))) +} diff --git a/packages/gemini-markets-cli/internal/session/session_test.go b/packages/gemini-markets-cli/internal/session/session_test.go new file mode 100644 index 00000000..ab5fec1f --- /dev/null +++ b/packages/gemini-markets-cli/internal/session/session_test.go @@ -0,0 +1,188 @@ +package session + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/gemini/developer-platform/packages/gemini-markets-cli/internal/credentials" + gemini "github.com/gemini/developer-platform/packages/sdk-go" + "github.com/gemini/developer-platform/packages/sdk-go/auth" +) + +func TestNewSelectsAuthModes(t *testing.T) { + tests := []struct { + name string + credentials credentials.Credentials + want AuthMode + }{ + {name: "none", want: AuthNone}, + {name: "hmac", credentials: credentials.Credentials{APIKey: "key", APISecret: "secret"}, want: AuthHMAC}, + {name: "bearer", credentials: credentials.Credentials{AccessToken: "token"}, want: AuthBearer}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + s, err := New(Config{Environment: gemini.Sandbox, Credentials: test.credentials}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer s.Close() + if s.AuthMode != test.want { + t.Fatalf("AuthMode = %q, want %q", s.AuthMode, test.want) + } + if s.Environment != gemini.Sandbox { + t.Fatalf("Environment = %q, want sandbox", s.Environment) + } + }) + } +} + +func TestNewBuildsDynamicOAuthSource(t *testing.T) { + called := false + s, err := New(Config{ + Environment: gemini.Production, + Credentials: credentials.Credentials{AccessToken: "access", RefreshToken: "refresh", OAuthClientID: "client"}, + OAuthSourceFactory: func(environment gemini.Environment, value credentials.Credentials, client *http.Client) (auth.TokenSource, error) { + called = true + if environment != gemini.Production || value.RefreshToken != "refresh" || client != nil { + t.Fatalf("factory arguments = %q, %#v, %v", environment, value, client) + } + return auth.TokenFunc(func(context.Context) (string, error) { return "token", nil }), nil + }, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer s.Close() + if !called || s.AuthMode != AuthOAuth { + t.Fatalf("factory called = %t, AuthMode = %q", called, s.AuthMode) + } +} + +func TestNewRejectsRefreshTokenWithoutClientID(t *testing.T) { + _, err := New(Config{Credentials: credentials.Credentials{RefreshToken: "refresh"}}) + if !errors.Is(err, ErrOAuthClientIDRequired) { + t.Fatalf("New() error = %v, want ErrOAuthClientIDRequired", err) + } +} + +func TestNewRejectsInvalidEnvironment(t *testing.T) { + _, err := New(Config{Environment: gemini.Environment("staging")}) + if !errors.Is(err, gemini.ErrInvalidEnvironment) { + t.Fatalf("New() error = %v, want ErrInvalidEnvironment", err) + } +} + +func TestAuthStrategySelectsNonceContractForSessionSurface(t *testing.T) { + credentials := credentials.Credentials{APIKey: "key", APISecret: "secret"} + _, restStrategy, err := authStrategyFor(Config{Credentials: credentials}, gemini.Production) + if err != nil { + t.Fatalf("REST authStrategyFor() error = %v", err) + } + restRequest, err := http.NewRequest(http.MethodPost, "https://api.gemini.com/v1/account", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + if err := restStrategy.Authenticate(context.Background(), restRequest, nil); err != nil { + t.Fatalf("REST Authenticate() error = %v", err) + } + decoded, err := base64.StdEncoding.DecodeString(restRequest.Header.Get("X-GEMINI-PAYLOAD")) + if err != nil { + t.Fatalf("decode REST payload: %v", err) + } + var payload map[string]string + if err := json.Unmarshal(decoded, &payload); err != nil { + t.Fatalf("decode REST payload JSON: %v", err) + } + if len(payload["nonce"]) < len(strconv.FormatInt(time.Now().Unix(), 10))+3 { + t.Fatalf("REST nonce = %q, want strict-increasing millisecond nonce", payload["nonce"]) + } + wsAuth, ok := restStrategy.(interface { + AuthenticateWebSocket(context.Context, *http.Request) error + }) + if !ok { + t.Fatal("REST strategy does not expose WebSocket auth capability") + } + wsRequest, _ := http.NewRequest(http.MethodGet, "wss://ws.gemini.com/v1/orders", nil) + if err := wsAuth.AuthenticateWebSocket(context.Background(), wsRequest); !errors.Is(err, auth.ErrTimeBasedNonceRequired) { + t.Fatalf("REST strategy WebSocket auth error = %v, want ErrTimeBasedNonceRequired", err) + } + + _, privateStrategy, err := authStrategyFor(Config{Credentials: credentials, PrivateWebSockets: true}, gemini.Production) + if err != nil { + t.Fatalf("private authStrategyFor() error = %v", err) + } + privateWSAuth, ok := privateStrategy.(interface { + AuthenticateWebSocket(context.Context, *http.Request) error + }) + if !ok { + t.Fatal("private strategy does not expose WebSocket auth capability") + } + privateRequest, _ := http.NewRequest(http.MethodGet, "wss://ws.gemini.com/v1/orders", nil) + if err := privateWSAuth.AuthenticateWebSocket(context.Background(), privateRequest); err != nil { + t.Fatalf("private strategy WebSocket auth error = %v", err) + } + nonce, err := strconv.ParseInt(privateRequest.Header.Get("X-GEMINI-NONCE"), 10, 64) + if err != nil || nonce <= 0 || nonce > time.Now().Add(time.Second).Unix() { + t.Fatalf("private WebSocket nonce = %q, want current epoch seconds", privateRequest.Header.Get("X-GEMINI-NONCE")) + } +} + +type sessionMemoryKeyring struct { + value credentials.Credentials + sets int +} + +func (k *sessionMemoryKeyring) Get(context.Context, string) (credentials.Credentials, error) { + return k.value, nil +} + +func (k *sessionMemoryKeyring) Set(_ context.Context, _ string, value credentials.Credentials) error { + k.value = value + k.sets++ + return nil +} + +func (k *sessionMemoryKeyring) Delete(context.Context, string) error { return nil } + +type sessionRoundTripper struct{} + +func (sessionRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"access_token":"rotated-access","refresh_token":"rotated-refresh","token_type":"Bearer","expires_in":3600}`)), + }, nil +} + +func TestOAuthRefreshPersistsCompleteCredentialSnapshot(t *testing.T) { + store := &sessionMemoryKeyring{} + source, err := newOAuthSource(Config{ + Environment: gemini.Production, + Credentials: credentials.Credentials{ + AccessToken: "expired-access", RefreshToken: "initial-refresh", + OAuthClientID: "client-id", OAuthClientSecret: "client-secret", + ExpiresAt: time.Now().Add(-time.Hour), + }, + OAuthHTTPClient: &http.Client{Transport: sessionRoundTripper{}}, + CredentialStore: store, + CredentialProfile: "work", + }, gemini.Production) + if err != nil { + t.Fatalf("newOAuthSource() expired error = %v", err) + } + got, err := source.Token(context.Background()) + if err != nil || got != "rotated-access" { + t.Fatalf("Token() = %q, %v; want rotated-access, nil", got, err) + } + if store.sets != 1 || store.value.AccessToken != "rotated-access" || store.value.RefreshToken != "rotated-refresh" || store.value.OAuthClientID != "client-id" || store.value.OAuthClientSecret != "client-secret" { + t.Fatalf("persisted credentials = %#v (sets=%d), want complete rotated snapshot", store.value, store.sets) + } +} diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 59ff2557..2d2b79c8 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -225,7 +225,13 @@ token, err := oauthConfig.Login(ctx, openBrowser) if err != nil { log.Fatal(err) } -source, err := oauth.NewTokenSource(oauthConfig, *token) +source, err := oauth.NewTokenSource( + oauthConfig, + *token, + oauth.WithTokenUpdate(func(ctx context.Context, updated oauth.Token) error { + return secureTokenStore.Save(ctx, updated) + }), +) if err != nil { log.Fatal(err) } @@ -235,8 +241,10 @@ client := gemini.NewClient(gemini.WithTokenSource(source)) `oauth.Source` refreshes once for concurrent callers, honors cancellation while waiting for another refresh, and preserves a refresh token when the provider omits it from a rotation response. It does not write credentials to -disk or a keychain; callers may load and persist tokens through their own -secure storage. +disk or a keychain. Applications that need durable refresh-token rotation can +pass `oauth.WithTokenUpdate`; the callback receives the complete refreshed +token before it becomes visible to callers. If persistence fails, the refresh +fails and the previous in-memory token remains in place. If a token source fails during an automatic WebSocket reconnect, the SDK stops that reconnect loop, reports the underlying error through connection events, and leaves the client disconnected so the source can be repaired before a diff --git a/packages/sdk-go/oauth/oauth.go b/packages/sdk-go/oauth/oauth.go index a544dc1e..5f24bdf1 100644 --- a/packages/sdk-go/oauth/oauth.go +++ b/packages/sdk-go/oauth/oauth.go @@ -63,12 +63,44 @@ var ( ErrRefreshTokenUnavailable = errors.New("gemini oauth: refresh token unavailable") // ErrTokenRefresh identifies a failed refresh operation. ErrTokenRefresh = errors.New("gemini oauth: token refresh failed") + // ErrTokenUpdate identifies a failure in an application-provided token + // update callback. The callback error is available through errors.Is, but + // is not included in Error output because it may contain token material. + ErrTokenUpdate = errors.New("gemini oauth: token update failed") // ErrTokenEndpoint indicates that an OAuth token endpoint rejected a // request. ErrTokenEndpoint = errors.New("gemini oauth: token endpoint rejected request") ) -var callbackResponseTemplate = template.Must(template.New("oauth-callback-response").Parse("{{.}}")) +var callbackResponseTemplate = template.Must(template.New("oauth-callback-response").Parse(` + +
+ + +{{.}}
+ +