Skip to content

Commit 86bba40

Browse files
committed
INF-1307 rewrite UX to kubectl-style verb tree
Replaced the per-API command tree (twoctl checkout/billing-account/...) with a kubectl-style verb tree (twoctl get/create/delete/edit <resource>). Every operation is classified by verb based on its operationId prefix (or HTTP method fall-back) and registered under the matching verb. The resource name falls out of the rest of the operationId, with python <locals> noise, HTTP method suffixes, and _handler decorations stripped. Top-level commands now mirror kubectl: twoctl get <resource> twoctl explain <verb> <resource> twoctl create <resource> twoctl api-resources twoctl delete <resource> twoctl config set-context|use-context| twoctl edit <resource> get-contexts|current-context| twoctl version delete-context The --context flag is a kubectl-style alias of --env. No legacy aliases retained - this is a UX rewrite, not an addition.
1 parent 6a4b09d commit 86bba40

23 files changed

Lines changed: 2788 additions & 335 deletions

Makefile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.PHONY: build install regen test test-cover lint tidy clean release-dry help
2+
3+
BINARY := twoctl
4+
PKG := github.com/two-inc/twoctl-cli
5+
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
6+
LDFLAGS := -X $(PKG)/internal/httpx.Version=$(VERSION) -s -w
7+
8+
help: ## Show this help.
9+
@awk 'BEGIN {FS = ":.*##"; printf "Usage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " \033[36m%-14s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
10+
11+
build: ## Build the twoctl binary into ./twoctl.
12+
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/twoctl
13+
14+
install: ## Install twoctl into $$GOBIN (or $$GOPATH/bin).
15+
go install -ldflags "$(LDFLAGS)" ./cmd/twoctl
16+
17+
regen: ## Refresh embedded OpenAPI specs from openapi/<api>-api.yaml.
18+
./scripts/codegen.sh
19+
20+
test: ## Run all unit tests.
21+
go test ./...
22+
23+
test-cover: ## Run tests with coverage and print per-package + total.
24+
go test -coverprofile=coverage.out -covermode=atomic ./...
25+
go tool cover -func=coverage.out | tail -1
26+
@echo "html report: open coverage.html"
27+
go tool cover -html=coverage.out -o coverage.html
28+
29+
lint: ## Run the same lints that goreportcard.com runs.
30+
./scripts/lint.sh
31+
32+
tidy: ## Tidy go.mod / go.sum.
33+
go mod tidy
34+
35+
release-dry: ## Dry-run a goreleaser build for the current OS.
36+
goreleaser build --snapshot --clean --single-target
37+
38+
clean: ## Remove build artefacts.
39+
rm -f $(BINARY) coverage.out coverage.html
40+
rm -rf dist/ .build/

README.md

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -86,39 +86,74 @@ After installing, open a new shell. Tab-completion works on subcommands,
8686
flags, and any flag value that has a known enum in the OpenAPI spec
8787
(country codes, currencies, statuses).
8888

89-
## Authenticate
89+
## Contexts
9090

91-
`twoctl` reads your API key from (in order):
91+
`twoctl` uses named contexts (kubectl-style). Each context bundles a base URL
92+
and an OS-keychain entry holding the API key, so you can switch between
93+
sandbox, prod, staging, cyber, perf, release, or any custom environment
94+
without copy-pasting keys.
9295

93-
1. `--api-key` flag
94-
2. `TWO_API_KEY` env var
95-
3. `~/.config/two/config.yaml`
96+
```sh
97+
twoctl ctx set sandbox --url https://api.sandbox.two.inc --key secret_test_...
98+
twoctl ctx set prod --url https://api.two.inc --key secret_prod_...
99+
twoctl ctx list # show all contexts, * marks current
100+
twoctl ctx use prod # switch the default
101+
twoctl ctx current # print the current name
102+
twoctl ctx delete temp # remove + clear keychain entry
103+
```
104+
105+
Per-invocation overrides (no switching):
96106

97107
```sh
98-
twoctl auth login # prompts for an API key
99-
twoctl auth whoami # verify the key works
108+
twoctl --env prod company company-search --country NO --q two
109+
twoctl --context cyber checkout get-order --order-id abc # kubectl-style alias
110+
twoctl --url http://localhost:8080 --api-key secret_test_x ...
100111
```
101112

102-
API keys are obtained from the [Two merchant portal](https://portal.two.inc).
103-
Sandbox keys are prefixed `secret_test_`; production keys `secret_prod_`. `twoctl`
104-
infers the environment from the prefix; override with `--env sandbox|prod`.
113+
For built-in env names (`prod`, `sandbox`, `staging`, `cyber`, `perf`,
114+
`release`) twoctl knows the URL. For any other name it falls back to
115+
`https://api.<name>.two.inc`; override with `--url`.
116+
117+
API key resolution order (highest precedence first):
118+
119+
1. `--api-key` flag
120+
2. `TWO_API_KEY` env var
121+
3. OS keychain entry for the active context
122+
123+
Keys are obtained from the [Two merchant portal](https://portal.two.inc).
105124

106125
## Usage
107126

108-
The command tree mirrors the API surface:
127+
The command tree is kubectl-style. Every operation across every Two API
128+
classifies into one of `get`, `create`, `delete`, `edit` based on its
129+
operationId, then registers as `twoctl <verb> <resource>`:
109130

131+
```sh
132+
twoctl get order --order-id abc-123 # GET /v1/order/{order_id}
133+
twoctl create order --file order.json # POST /v1/order
134+
twoctl delete order --order-id abc-123 # POST /v1/order/{id}/cancel
135+
twoctl edit order --order-id abc-123 --file patch.json
136+
twoctl get company --q "two" --country NO # company search
137+
twoctl get billing-accounts # list
138+
twoctl explain create order # JSON schema for the operation
139+
twoctl api-resources --verb get # full catalog of get verbs
110140
```
111-
twoctl checkout order-create --file order.json
112-
twoctl checkout order-get <order-id>
113-
twoctl billing-account list
114-
twoctl repay payment-create ...
115-
twoctl recourse ...
116-
twoctl company search --org-no 123456789 --country NO
117-
twoctl limits buyer-get <buyer-id>
118-
```
119141

120-
Every subcommand supports `--help` listing flags derived from the OpenAPI
121-
operation. Request bodies can be passed via `--file path.json` or `--data '{...}'`.
142+
Run `twoctl get` / `twoctl create` etc. on its own to see every resource
143+
under that verb. Each leaf command's `--help` lists flags derived from the
144+
OpenAPI operation; request bodies go via `--file path.json` or `--data
145+
'{...}'`.
146+
147+
Top-level commands mirror kubectl:
148+
149+
| `twoctl` | `kubectl` analogue |
150+
| --- | --- |
151+
| `get` / `create` / `delete` / `edit` | the same verbs |
152+
| `explain <verb> <resource>` | `explain` |
153+
| `api-resources` | `api-resources` |
154+
| `config set-context / use-context / get-contexts / current-context / delete-context` | the same |
155+
| `version` | `version` |
156+
| `--context` / `--env` (alias) | `--context` |
122157

123158
## Agent-friendly features
124159

@@ -165,14 +200,20 @@ twoctl upgrade --enable-autocheck # turn it back on
165200

166201
## APIs covered
167202

168-
| API | Subcommand | Spec |
169-
| --- | --- | --- |
170-
| Checkout (Order) | `twoctl checkout` | [checkout-api.yaml](openapi/checkout-api.yaml) |
171-
| Billing Account | `twoctl billing-account` | [billing-account-api.yaml](openapi/billing-account-api.yaml) |
172-
| Repay | `twoctl repay` | [repay-api.yaml](openapi/repay-api.yaml) |
173-
| Recourse | `twoctl recourse` | [recourse-api.yaml](openapi/recourse-api.yaml) |
174-
| Company | `twoctl company` | [company-api.yaml](openapi/company-api.yaml) |
175-
| Limits | `twoctl limits` | [limits-api.yaml](openapi/limits-api.yaml) |
203+
Every operation across all six merchant-facing specs surfaces as a `twoctl
204+
<verb> <resource>` command. The verbs are auto-derived from operationId
205+
prefixes (get_/retrieve_/list_/search_, create_/make_/fulfill_/refund_/cancel_/...
206+
etc.), with HTTP-method fall-back when no prefix matches. Run
207+
`twoctl api-resources` for the full machine-readable catalog.
208+
209+
| Spec | Source |
210+
| --- | --- |
211+
| Checkout (Order) | [checkout-api.yaml](openapi/checkout-api.yaml) |
212+
| Billing Account | [billing-account-api.yaml](openapi/billing-account-api.yaml) |
213+
| Repay | [repay-api.yaml](openapi/repay-api.yaml) |
214+
| Recourse | [recourse-api.yaml](openapi/recourse-api.yaml) |
215+
| Company | [company-api.yaml](openapi/company-api.yaml) |
216+
| Limits | [limits-api.yaml](openapi/limits-api.yaml) |
176217

177218
## Regeneration
178219

cmd/twoctl/cli/auth.go

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import (
1515
func init() {
1616
authCmd := &cobra.Command{
1717
Use: "auth",
18-
Short: "Manage API key authentication",
18+
Short: "Manage API keys for the active context",
19+
Long: `Store, remove, and inspect the API key for a context.
20+
21+
By default, auth subcommands operate on the current context (set by
22+
` + "`twoctl ctx use`" + `). Pass --context to target a different one.`,
1923
}
2024
authCmd.AddCommand(
2125
authLoginCmd(),
@@ -26,17 +30,15 @@ func init() {
2630
}
2731

2832
func authLoginCmd() *cobra.Command {
29-
var keyFlag string
33+
var keyFlag, ctxFlag string
3034
cmd := &cobra.Command{
3135
Use: "login",
32-
Short: "Store an API key in the OS keychain",
33-
Long: `Store a Two API key in the OS keychain (macOS Keychain, GNOME Keyring,
34-
Windows Credential Manager). The key is read from --key, or prompted for
35-
interactively if not provided.
36-
37-
twoctl prefers the keychain over the TWO_API_KEY environment variable when
38-
both are set.`,
36+
Short: "Store an API key in the OS keychain for a context",
3937
RunE: func(cmd *cobra.Command, args []string) error {
38+
target, err := pickContext(ctxFlag)
39+
if err != nil {
40+
return err
41+
}
4042
key := strings.TrimSpace(keyFlag)
4143
if key == "" {
4244
k, err := promptAPIKey(cmd)
@@ -48,47 +50,71 @@ both are set.`,
4850
if !looksLikeAPIKey(key) {
4951
return fmt.Errorf("that does not look like a Two API key (expected prefix secret_test_ or secret_prod_)")
5052
}
51-
if err := config.StoreKey(key); err != nil {
53+
if err := config.StoreKey(target, key); err != nil {
5254
return fmt.Errorf("storing key in keychain: %w", err)
5355
}
54-
fmt.Fprintln(cmd.OutOrStdout(), "stored API key in OS keychain")
56+
fmt.Fprintf(cmd.OutOrStdout(), "stored API key for context %q\n", target)
5557
return nil
5658
},
5759
}
5860
cmd.Flags().StringVar(&keyFlag, "key", "", "API key to store (will prompt if omitted)")
61+
cmd.Flags().StringVar(&ctxFlag, "context", "", "context to store the key under (default: current context)")
5962
return cmd
6063
}
6164

6265
func authLogoutCmd() *cobra.Command {
63-
return &cobra.Command{
66+
var ctxFlag string
67+
cmd := &cobra.Command{
6468
Use: "logout",
65-
Short: "Remove the API key from the OS keychain",
69+
Short: "Remove the API key for a context",
6670
RunE: func(cmd *cobra.Command, args []string) error {
67-
if err := config.DeleteKey(); err != nil {
68-
return fmt.Errorf("removing key: %w", err)
71+
target, err := pickContext(ctxFlag)
72+
if err != nil {
73+
return err
74+
}
75+
if err := config.DeleteKey(target); err != nil {
76+
return err
6977
}
70-
fmt.Fprintln(cmd.OutOrStdout(), "removed cached API key")
78+
fmt.Fprintf(cmd.OutOrStdout(), "removed API key for context %q\n", target)
7179
return nil
7280
},
7381
}
82+
cmd.Flags().StringVar(&ctxFlag, "context", "", "context to forget (default: current context)")
83+
return cmd
7484
}
7585

7686
func authWhoamiCmd() *cobra.Command {
7787
return &cobra.Command{
7888
Use: "whoami",
79-
Short: "Show which API key and environment will be used",
89+
Short: "Show the active context, API URL, and key provenance",
8090
RunE: func(cmd *cobra.Command, args []string) error {
81-
resolved, err := config.Resolve(flagAPIKey, flagEnv)
91+
resolved, err := config.Resolve(flagAPIKey, activeEnv(), flagURL)
8292
if err != nil {
8393
return err
8494
}
85-
fmt.Fprintf(cmd.OutOrStdout(), "environment: %s (%s)\nkey source: %s\nkey prefix: %s\n",
86-
resolved.Env, resolved.Env.BaseURL(), resolved.Source, redact(resolved.APIKey))
95+
fmt.Fprintf(cmd.OutOrStdout(), "context: %s\nurl: %s\nkey source: %s\nkey prefix: %s\n",
96+
orDash(resolved.ContextName), resolved.BaseURL, resolved.Source, redactKey(resolved.APIKey))
8797
return nil
8898
},
8999
}
90100
}
91101

102+
// pickContext returns the explicit override if provided, otherwise the
103+
// current context name from config. Errors if neither is set.
104+
func pickContext(override string) (string, error) {
105+
if override != "" {
106+
return override, nil
107+
}
108+
cfg, err := config.LoadFile()
109+
if err != nil {
110+
return "", err
111+
}
112+
if cfg.CurrentContext == "" {
113+
return "", fmt.Errorf("no current context - pass --context or run `twoctl ctx set <name> --url <url>`")
114+
}
115+
return cfg.CurrentContext, nil
116+
}
117+
92118
func promptAPIKey(cmd *cobra.Command) (string, error) {
93119
fmt.Fprint(cmd.OutOrStdout(), "Two API key: ")
94120
if term.IsTerminal(int(os.Stdin.Fd())) {
@@ -114,18 +140,24 @@ func looksLikeAPIKey(s string) bool {
114140
strings.HasPrefix(s, "secret_sandbox_")
115141
}
116142

117-
// redact returns a key with everything between the env prefix and the last
118-
// four characters replaced by asterisks. Used for whoami output only.
119-
func redact(key string) string {
143+
// redactKey returns the key with everything between the env prefix and the
144+
// last four characters masked.
145+
func redactKey(key string) string {
120146
if len(key) < 16 {
121147
return "****"
122148
}
123-
prefix := key
124149
tail := key[len(key)-4:]
125150
if idx := strings.Index(key, "_"); idx >= 0 {
126151
if idx2 := strings.Index(key[idx+1:], "_"); idx2 >= 0 {
127-
prefix = key[:idx+1+idx2+1]
152+
return key[:idx+1+idx2+1] + "****" + tail
128153
}
129154
}
130-
return prefix + "****" + tail
155+
return "****" + tail
156+
}
157+
158+
func orDash(s string) string {
159+
if s == "" {
160+
return "-"
161+
}
162+
return s
131163
}

0 commit comments

Comments
 (0)