Skip to content

Commit e1f967b

Browse files
committed
INF-1307 twoctl: spec-driven command-line for every Two API
A public Go CLI for the Two merchant APIs. Resource-verb command tree (`twoctl <resource> <action>`) generated entirely from the published OpenAPI specs in two-inc/docs, with auth, contexts, self-upgrade, and agent-friendly UX out of the box. API coverage - Six merchant-facing APIs: checkout, billing-account, repay, recourse, company, limits. Every operationId surfaces as `twoctl <resource> <action>` with non-CRUD actions (cancel, refund, fulfill, search, notify, ...) as first-class verbs alongside get/create/edit/delete. - Spec-driven: cmd/twoctl/cli embeds the six processed OpenAPI specs and walks them at startup. No per-operation Go code to maintain. - internal/preprocess rewrites OAS 3.1 nullable patterns into a 3.0- compatible shape so the openapi3 parser can consume the upstream specs unchanged. Contexts (kubectl-style) - Named contexts each bundle a base URL and an OS-keychain entry. `twoctl config set-context / use-context / get-contexts / current-context / delete-context` manage them. - Built-in env aliases: prod, sandbox, staging, cyber, perf, release. Unknown names fall back to https://api.<name>.two.inc. - Per-invocation overrides without switching: --env / --context / --url / --api-key. Key resolution order: --api-key, TWO_API_KEY, keychain. - Config at $XDG_CONFIG_HOME/twoctl or ~/.config/twoctl (XDG, never the macOS Library default). - `twoctl auth login / logout / whoami` for keychain management. - Keychain fallback detection: clear error when libsecret/DBus is missing on headless Linux instead of an opaque failure. Agent + human UX - `twoctl api-resources [--resource X] [--action Y]` emits a JSON catalog of every operation (command path, method, URL template, flags with kind/type/required, body status) for agent planners. - `twoctl explain <resource> <action>` prints the operation's parameter, request body, and response schemas as JSON without a network call. - `--describe` / `--dry-run` on every leaf operation. Dry-run prints the HTTP request that would be sent with sensitive headers redacted. - `-o auto|table|json|yaml`: auto-detects TTY for default (table on terminal, json when piped). Tables render arrays of objects with union-of-keys columns; non-tabular shapes fall back to json. - `--all` flag auto-registered on operations with `page_cursor`; follows `next_page_cursor` until exhausted and concatenates pages. - Structured stderr error envelope: {error, message, status, response, request_id}. Captures upstream X-Request-Id / X-Trace-Id / X-Correlation-Id / Request-Id headers for support tickets. - Stable exit codes: 0 ok, 1 generic, 2 usage, 3 auth, 4 not-found, 5 rate-limited, 6 server, 7 network. Documented in README. - Required parameters render as "(path, required)" in --help. - Friendly HTTP error messages: 401 points at `twoctl auth login`, 429 mentions rate limiting, 5xx suggests retry. HTTP transport - Shared transport injects X-Api-Key, User-Agent (`twoctl/<ver> (<os>/<arch>)`), and Accept on every call. - Exponential backoff with jitter on 429 and 5xx, capped at 3 retries by default. Honours `Retry-After` header. - Idempotency-Key (`twoctl-<hex>`) auto-set on POST/PUT/PATCH/DELETE so a retried mutation never duplicates. - Request body buffered before send so retries can replay it. - Response body capped at 4 MiB to prevent OOM on misbehaving servers. - Context cancellation stops the retry loop cleanly. Self-upgrade - Once-per-day check on every invocation against the GitHub releases API. Network failures during the check are silent so they never block the actual command. - Interactive prompt offers `y` install now / `n` not now / `s` skip this version. Skip list persists; `twoctl upgrade --reset-skips` clears it. - Non-interactive shells (no TTY) get a single-line stderr nudge. - `twoctl upgrade` runs the check + install on demand; `--check`, `--disable-autocheck`, `--enable-autocheck` available. - `TWOCTL_SKIP_UPGRADE_CHECK=1` env var and `--no-upgrade-check` flag for scripts/CI. - Binary swap via minio/selfupdate; supports tar.gz and zip archives. - State persisted to ~/.config/twoctl/state.json. Shell completion - bash, zsh, fish, powershell via `twoctl completion <shell>`. - Enum-valued OpenAPI parameters get value-completion registered automatically (e.g. tab-complete --country with ISO codes). Tooling + CI - Makefile with `build / install / regen / test / test-cover / lint / tidy / release-dry / clean`. `make help` self-documents. - `make install` injects git describe --tags --always --dirty into the binary via ldflags so dev builds carry a version. - scripts/lint.sh runs every Go Report Card tool (gofmt -s, go vet, ineffassign, misspell, gocyclo over 15, golint); exits non-zero on any output. - CI: build + `make test-cover` gates coverage at 80% (currently 81.3%) and the six-lint sweep runs on every PR. - .github/workflows/regenerate.yml: repository_dispatch from two-inc/docs on openapi/* changes pulls fresh specs, runs codegen, opens a PR. Nightly cron as safety net; gracefully exits when DOCS_READ_TOKEN is unset. - .github/workflows/release.yml + .goreleaser.yaml: tag push builds darwin/linux/windows binaries (amd64 + arm64) and updates the two-inc/homebrew-tap formula. - docs/notify-twoctl.yml.example: drop-in workflow for two-inc/docs to fire the dispatch. Telemetry stance - No telemetry, no phone-home, no anonymous usage metrics. Outbound network calls are limited to the configured API endpoint and the GitHub releases API for upgrade checks. Documented in README. Linear: https://linear.app/tillit/issue/INF-1307 Shared CLI patterns reference: https://linear.app/tillit/issue/INF-1314 Adversarial review applied (INF-1318) - C1+C2+B1 self-update hardened: SHA-256 verified against published checksums.txt, strict regex asset selection, 100 MiB download + 200 MiB decompression caps, tar regular-file enforcement. - C3 pagination max-page cap, cursor-seen-set, null-items guard. - C4 response-cap fails loud (response_too_large) instead of truncating. - C5 flag presence via cobra Flags().Changed (deliberate 0 / false sent). - C6 path/query templating uses spec original parameter name. - C7 redactKey conservative on non-API-key inputs. - C8 normaliseURL rejects http://, query/fragment/userinfo, malformed URLs. - C9 semver comparisons via golang.org/x/mod/semver. - C10 looksLikeAPIKey is regex-strict (charset + length + no control chars). - C11 README rewritten: removed phantom ctx/catalog commands. - C12 dead apiMeta tree removed. - O5 API key validation rejects embedded control characters. - O7 redactHeaders allowlist matches any sensitive-looking header name. - O8 error envelope redacts API-key-shaped values + sensitive JSON keys. - O9 config dir 0o700, file 0o600. - O11 context names validated against ^[a-z0-9][a-z0-9._-]{0,62}$. - O13 crypto/rand failure panics (no predictable fallback for Idempotency-Key). - V2+V3 built-in --describe/--dry-run/--all/--file/--data collisions detected and renamed at startup; spec params win. - V6 stable disambiguator (operationId-derived suffix, not counter). - V7 cross-spec resource collisions get API-name qualifier. - V10 paginated body read once, not re-read per page. - V12 json.Decoder.UseNumber + json.Number table cell → preserves IDs > 2^53. - V16 --url drops context name so production key can't be sent to a typo URL. - V19 isNullSchema accepts {type: null, description: ...} sibling decorations. - Han-3+4+13 throttle persists NextCheckAt before network call; caches full Release payload to avoid double-fetch. - Han-6 backoff attempt clamped to prevent shift overflow. - Han-7 retry transport no longer mutates caller's req.Body. - Han-8 Apply failure prints recovery hint pointing at releases page. - Han-11 main() wires SIGINT to root context; in-flight requests cancel.
0 parents  commit e1f967b

79 files changed

Lines changed: 66198 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v5
17+
- uses: actions/setup-go@v6
18+
with:
19+
go-version: '1.26'
20+
cache: true
21+
- run: go build ./...
22+
- run: make test-cover
23+
24+
lint:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v5
28+
- uses: actions/setup-go@v6
29+
with:
30+
go-version: '1.26'
31+
cache: true
32+
- name: Install lint tools
33+
run: |
34+
go install github.com/gordonklaus/ineffassign@latest
35+
go install github.com/client9/misspell/cmd/misspell@latest
36+
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
37+
go install golang.org/x/lint/golint@latest
38+
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
39+
- run: ./scripts/lint.sh

.github/workflows/regenerate.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: regenerate
2+
3+
# Pulls the latest OpenAPI specs from two-inc/docs and regenerates the
4+
# vendored copies + clients. Opens a PR if anything changed.
5+
#
6+
# Triggered by:
7+
# - manual dispatch
8+
# - repository_dispatch (event_type: openapi-updated), fired by a workflow
9+
# in two-inc/docs whenever openapi/* files change on main.
10+
# - nightly cron (safety net in case the dispatch is dropped)
11+
12+
on:
13+
workflow_dispatch: {}
14+
repository_dispatch:
15+
types: [openapi-updated]
16+
schedule:
17+
- cron: '0 3 * * *'
18+
19+
permissions:
20+
contents: write
21+
pull-requests: write
22+
23+
jobs:
24+
regen:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v5
28+
- uses: actions/setup-go@v6
29+
with:
30+
go-version: '1.26'
31+
cache: true
32+
33+
- name: Fetch latest specs from two-inc/docs
34+
env:
35+
GH_TOKEN: ${{ secrets.DOCS_READ_TOKEN }}
36+
run: |
37+
set -euo pipefail
38+
if [[ -z "${GH_TOKEN:-}" ]]; then
39+
echo "::notice::DOCS_READ_TOKEN not configured; skipping regeneration."
40+
echo "Add a fine-grained PAT with read access to two-inc/docs as the"
41+
echo "DOCS_READ_TOKEN repo secret to enable automatic regeneration."
42+
exit 0
43+
fi
44+
for api in \
45+
checkout billing-account repay recourse company limits \
46+
autofill business-registration marketplace \
47+
trade-account-v2 trade-account-v3 webhooks; do
48+
gh api repos/two-inc/docs/contents/openapi/${api}-api.yaml \
49+
--jq '.content' \
50+
-H "Accept: application/vnd.github.v3+json" \
51+
| base64 -d > openapi/${api}-api.yaml
52+
done
53+
54+
- run: ./scripts/codegen.sh
55+
56+
- run: go build ./... && go vet ./...
57+
58+
- name: Open PR if changed
59+
uses: peter-evans/create-pull-request@v7
60+
with:
61+
commit-message: 'chore: regenerate clients from latest openapi specs'
62+
title: 'chore: regenerate clients from latest openapi specs'
63+
branch: regen/openapi
64+
delete-branch: true
65+
body: |
66+
Regenerated from the latest openapi specs in [two-inc/docs](https://github.com/two-inc/docs/tree/main/openapi).
67+
68+
This PR was opened automatically by the `regenerate` workflow.

.github/workflows/release.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags: ['v*']
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
release:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v5
15+
with:
16+
fetch-depth: 0
17+
- uses: actions/setup-go@v6
18+
with:
19+
go-version: '1.26'
20+
cache: true
21+
- uses: goreleaser/goreleaser-action@v6
22+
with:
23+
version: latest
24+
args: release --clean
25+
env:
26+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/dist/
2+
/bin/
3+
/twoctl
4+
*.test
5+
*.out
6+
.DS_Store
7+
.idea/
8+
.vscode/
9+
.worktrees/
10+
coverage.txt
11+
.build/
12+
/preprocess
13+
coverage.html

.goreleaser.yaml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
version: 2
2+
3+
project_name: twoctl
4+
5+
before:
6+
hooks:
7+
- go mod tidy
8+
9+
builds:
10+
- id: twoctl
11+
main: ./cmd/twoctl
12+
binary: twoctl
13+
env:
14+
- CGO_ENABLED=0
15+
goos: [linux, darwin, windows]
16+
goarch: [amd64, arm64]
17+
ignore:
18+
- goos: windows
19+
goarch: arm64
20+
ldflags:
21+
- -s -w
22+
- -X github.com/two-inc/twoctl-cli/internal/httpx.Version={{.Version}}
23+
24+
archives:
25+
- id: twoctl
26+
name_template: >-
27+
{{ .ProjectName }}_
28+
{{- .Version }}_
29+
{{- .Os }}_
30+
{{- if eq .Arch "amd64" }}x86_64
31+
{{- else if eq .Arch "386" }}i386
32+
{{- else }}{{ .Arch }}{{ end }}
33+
format_overrides:
34+
- goos: windows
35+
format: zip
36+
37+
checksum:
38+
name_template: 'checksums.txt'
39+
40+
snapshot:
41+
version_template: '{{ incpatch .Version }}-next'
42+
43+
changelog:
44+
sort: asc
45+
filters:
46+
exclude:
47+
- '^docs:'
48+
- '^test:'
49+
- '^chore: regenerate'
50+
51+
brews:
52+
- name: twoctl
53+
repository:
54+
owner: two-inc
55+
name: homebrew-tap
56+
token: '{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}'
57+
homepage: 'https://github.com/two-inc/twoctl'
58+
description: 'Command-line interface for the Two merchant APIs'
59+
license: MIT
60+
install: |
61+
bin.install "twoctl"
62+
test: |
63+
system "#{bin}/twoctl --version"

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Two AS
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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; fail if below COVERAGE_THRESHOLD (default 80).
24+
go test -coverprofile=coverage.out -covermode=atomic ./...
25+
@total=$$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%/, "", $$3); print $$3 }'); \
26+
echo "total coverage: $$total%"; \
27+
awk -v t=$$total -v thresh=$(COVERAGE_THRESHOLD) 'BEGIN { if (t+0 < thresh+0) { exit 1 } }' || \
28+
{ echo "::error::coverage $$total% below threshold $(COVERAGE_THRESHOLD)%"; exit 1; }
29+
@echo "html report: open coverage.html"
30+
go tool cover -html=coverage.out -o coverage.html
31+
32+
COVERAGE_THRESHOLD ?= 80
33+
34+
lint: ## Run the same lints that goreportcard.com runs.
35+
./scripts/lint.sh
36+
37+
tidy: ## Tidy go.mod / go.sum.
38+
go mod tidy
39+
40+
release-dry: ## Dry-run a goreleaser build for the current OS.
41+
goreleaser build --snapshot --clean --single-target
42+
43+
clean: ## Remove build artefacts.
44+
rm -f $(BINARY) coverage.out coverage.html
45+
rm -rf dist/ .build/

0 commit comments

Comments
 (0)