Commit e1f967b
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
File tree
- .github/workflows
- cmd/twoctl
- cli
- specs
- docs
- internal
- config
- httpx
- output
- preprocess
- updater
- openapi
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
0 commit comments