Skip to content

Commit 4d76e69

Browse files
committed
INF-1307 initial twoctl scaffold
Public Go CLI for the Two merchant APIs, generated from the OpenAPI specs in two-inc/docs. Covers checkout, billing-account, repay, recourse, company, and limits - every operation surfaces as twoctl <api> <op>. - oapi-codegen typed clients in internal/clients/<api> - spec-driven cobra registration (cmd/twoctl/cli/operations.go) embeds the six processed specs and walks them at startup, so coverage tracks the source 1:1 with no per-operation Go code to maintain - preprocessor (internal/preprocess) downgrades OAS 3.1 nullable patterns into 3.0-compatible shape so oapi-codegen can consume the specs - API key auth: --api-key flag, TWO_API_KEY env, OS keychain via go-keyring (login/logout/whoami) - env inferred from key prefix (secret_test_* sandbox, secret_prod_* prod); --env to override - CI: regenerate.yml triggered by repository_dispatch from docs + nightly cron; release.yml runs goreleaser on tag for darwin/linux/windows + homebrew tap Linear: https://linear.app/tillit/issue/INF-1307
0 parents  commit 4d76e69

33 files changed

Lines changed: 38154 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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: go vet ./...
23+
- run: go test ./...

.github/workflows/regenerate.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 || github.token }}
36+
run: |
37+
set -euo pipefail
38+
for api in checkout billing-account repay recourse company limits; do
39+
gh api repos/two-inc/docs/contents/openapi/${api}-api.yaml \
40+
--jq '.content' \
41+
-H "Accept: application/vnd.github.v3+json" \
42+
| base64 -d > openapi/${api}-api.yaml
43+
done
44+
45+
- run: ./scripts/codegen.sh
46+
47+
- run: go build ./... && go vet ./...
48+
49+
- name: Open PR if changed
50+
uses: peter-evans/create-pull-request@v7
51+
with:
52+
commit-message: 'chore: regenerate clients from latest openapi specs'
53+
title: 'chore: regenerate clients from latest openapi specs'
54+
branch: regen/openapi
55+
delete-branch: true
56+
body: |
57+
Regenerated from the latest openapi specs in [two-inc/docs](https://github.com/two-inc/docs/tree/main/openapi).
58+
59+
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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

.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/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.

README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# twoctl
2+
3+
Command-line interface for the [Two](https://two.inc) merchant APIs. Generated from the [public OpenAPI specs](https://github.com/two-inc/docs/tree/main/openapi), so every endpoint is exposed as a subcommand.
4+
5+
## Install
6+
7+
### Homebrew
8+
9+
```sh
10+
brew install two-inc/tap/twoctl
11+
```
12+
13+
### Binary releases
14+
15+
Download from [releases](https://github.com/two-inc/twoctl/releases) and place on `$PATH`.
16+
17+
### From source
18+
19+
```sh
20+
go install github.com/two-inc/twoctl/cmd/twoctl@latest
21+
```
22+
23+
## Authenticate
24+
25+
`twoctl` reads your API key from (in order):
26+
27+
1. `--api-key` flag
28+
2. `TWO_API_KEY` env var
29+
3. `~/.config/two/config.yaml`
30+
31+
```sh
32+
twoctl auth login # prompts for an API key
33+
twoctl auth whoami # verify the key works
34+
```
35+
36+
API keys are obtained from the [Two merchant portal](https://portal.two.inc).
37+
Sandbox keys are prefixed `secret_test_`; production keys `secret_prod_`. `twoctl`
38+
infers the environment from the prefix; override with `--env sandbox|prod`.
39+
40+
## Usage
41+
42+
The command tree mirrors the API surface:
43+
44+
```
45+
twoctl checkout order-create --file order.json
46+
twoctl checkout order-get <order-id>
47+
twoctl billing-account list
48+
twoctl repay payment-create ...
49+
twoctl recourse ...
50+
twoctl company search --org-no 123456789 --country NO
51+
twoctl limits buyer-get <buyer-id>
52+
```
53+
54+
Every subcommand supports `--help` listing flags derived from the OpenAPI
55+
operation. Request bodies can be passed via `--file path.json` or `--data '{...}'`.
56+
57+
## APIs covered
58+
59+
| API | Subcommand | Spec |
60+
| --- | --- | --- |
61+
| Checkout (Order) | `twoctl checkout` | [checkout-api.yaml](openapi/checkout-api.yaml) |
62+
| Billing Account | `twoctl billing-account` | [billing-account-api.yaml](openapi/billing-account-api.yaml) |
63+
| Repay | `twoctl repay` | [repay-api.yaml](openapi/repay-api.yaml) |
64+
| Recourse | `twoctl recourse` | [recourse-api.yaml](openapi/recourse-api.yaml) |
65+
| Company | `twoctl company` | [company-api.yaml](openapi/company-api.yaml) |
66+
| Limits | `twoctl limits` | [limits-api.yaml](openapi/limits-api.yaml) |
67+
68+
## Regeneration
69+
70+
The `openapi/` specs are vendored from [`two-inc/docs`](https://github.com/two-inc/docs/tree/main/openapi).
71+
When that source changes, a `repository_dispatch` event fires the [`regenerate.yml`](.github/workflows/regenerate.yml) workflow,
72+
which pulls fresh specs, runs codegen, and opens a PR.
73+
74+
To regenerate locally:
75+
76+
```sh
77+
./scripts/codegen.sh
78+
go build ./...
79+
```
80+
81+
## Contributing
82+
83+
This repository is generated from the OpenAPI specs in [`two-inc/docs`](https://github.com/two-inc/docs).
84+
Bug reports and PRs for the CLI itself (flags, output formatting, ergonomics) are welcome here.
85+
For API behaviour or schema changes, please open an issue against `two-inc/docs`.
86+
87+
## Licence
88+
89+
MIT - see [LICENSE](LICENSE).

docs/notify-twoctl.yml.example

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Template to add to two-inc/docs/.github/workflows/notify-twoctl.yml
2+
#
3+
# Fires a repository_dispatch into two-inc/twoctl whenever the openapi/
4+
# specs change on the docs default branch. twoctl's `regenerate` workflow
5+
# listens for this event.
6+
#
7+
# Required secret in two-inc/docs:
8+
# TWOCTL_DISPATCH_TOKEN - a PAT (fine-grained, scope: contents:read +
9+
# actions:write) on the two-inc/twoctl repo.
10+
11+
name: notify-twoctl
12+
13+
on:
14+
push:
15+
branches: [main, staging]
16+
paths:
17+
- 'openapi/**'
18+
19+
jobs:
20+
dispatch:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Trigger twoctl regen
24+
env:
25+
GH_TOKEN: ${{ secrets.TWOCTL_DISPATCH_TOKEN }}
26+
run: |
27+
gh api repos/two-inc/twoctl/dispatches \
28+
-f event_type=openapi-updated \
29+
-f client_payload[ref]=${{ github.sha }}

docs/regeneration.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Regeneration pipeline
2+
3+
`twoctl` is rebuilt from the OpenAPI specs in [`two-inc/docs`](https://github.com/two-inc/docs/tree/main/openapi). The flow:
4+
5+
```
6+
docs/openapi/*.yaml change
7+
|
8+
v (push to main, paths: openapi/**)
9+
[notify-twoctl.yml in docs] --- repository_dispatch ----> [regenerate.yml in twoctl]
10+
|
11+
v
12+
1. fetch latest specs
13+
2. ./scripts/codegen.sh
14+
3. go build/vet
15+
4. open PR (peter-evans)
16+
```
17+
18+
A nightly cron in `regenerate.yml` is the safety net if a dispatch ever gets dropped.
19+
20+
## Wiring it up
21+
22+
### One-time setup in `two-inc/docs`
23+
24+
1. Create a fine-grained PAT scoped to `two-inc/twoctl` with `actions: write`. Store as `TWOCTL_DISPATCH_TOKEN`.
25+
2. Copy [`notify-twoctl.yml.example`](notify-twoctl.yml.example) into `.github/workflows/notify-twoctl.yml`.
26+
27+
### One-time setup in `two-inc/twoctl`
28+
29+
The `regenerate.yml` workflow uses `GITHUB_TOKEN` for PR creation. No extra secret needed unless you want it to read private docs (override `DOCS_READ_TOKEN`).
30+
31+
### Local regeneration
32+
33+
```sh
34+
./scripts/codegen.sh
35+
```
36+
37+
That preprocesses the OpenAPI 3.1 specs into 3.0-compatible shape (see [`internal/preprocess`](../internal/preprocess/main.go)), generates the typed Go clients into `internal/clients/<api>/zz_generated.go`, and copies the processed specs into `cmd/twoctl/cli/specs/` so the CLI can embed them.
38+
39+
## Releases
40+
41+
Push a `vX.Y.Z` tag. `release.yml` runs `goreleaser` which produces darwin/linux/windows binaries and updates the `two-inc/homebrew-tap` Brewfile (requires `HOMEBREW_TAP_GITHUB_TOKEN` repo secret).

0 commit comments

Comments
 (0)