Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,19 @@ accepted.
kaiten customers list
kaiten licenses get team-v1 --output yaml
kaiten licenses entitlements list team-v1
kaiten instances audit-trails acme-production --event-name instance.updated --after 2026-01-01T00:00:00Z --limit 100
kaiten instances audit-trails acme-production --event-name INSTANCE_UPDATED --after 2026-01-01T00:00:00Z --limit 100
```

`--output` (or `KAITEN_OUTPUT`) selects `table` (the default), `json` or `yaml`.
`--output` (or `KAITEN_OUTPUT`) selects `table`, `json` or `yaml`.

Without it, the format follows what the command returns. A list prints a table. A single
resource (`get`, `create`, a usage report, a new token) prints YAML, and so does
`--output table` there, a single resource having no columns. A command the API answers
with no body (`delete`, most `update`s) prints one line, or a document with a `message`
key under `--output json` and `--output yaml`.

JSON and YAML output carry the same document: the API's field names and values, in the
same order.

### Writing

Expand All @@ -137,6 +146,26 @@ kaiten components create --payload '{"name": "api", "version": "1.4.0"}'
Feature flags carry targeting rules and variants, so `feature-flags create` and
`update` take `--file` or `--payload` only.

A file or a payload uses the API's field names, in JSON or in YAML, and both make the
same request:

```yaml
# customer.yaml
name: Acme Corp
externalCustomerId: crm-4711
```

A YAML document is read as the JSON it stands for. A value written bare for a text field
stays text (`version: 1.0` is the version `1.0`), and a YAML key the request has no field
for is an error that names the key, before anything is sent. JSON skips such a key
instead, so a resource printed with `--output json` can be edited and written back with
the fields only the API sets (`id`, `createdAt`, ...) still in it; printed as YAML, it has
to lose them first.

With inline flags, `instances update` keeps what its optional flags leave out: the
deployment zone, the slug, and the metadata, which the command reads from the instance and
sends back unless `--metadata-json` gives another (`--metadata-json '{}'` clears it).

### Licenses and entitlements

```shell
Expand All @@ -154,11 +183,21 @@ kaiten licenses entitlements update team-v1 monthly-orders --number 1500
```

The API replaces an entitlement or a grant with what it is sent, resetting what the request
leaves out. So `entitlements update` and `licenses entitlements update` read the resource
first when given inline flags, and send back everything the flags do not change: a
periodic entitlement keeps its window, which the API refuses to see dropped, and a grant
keeps its allowance unless `--overage-percent` says otherwise. `--file` and `--payload` are
sent as written.
leaves out. So both update commands read the resource first and send back what the
invocation does not change, each in its own way:

- `entitlements update` does it with inline flags: every field the flags do not name is
sent back as it was read, so a periodic entitlement keeps its window, which the API
refuses to see dropped. `--file` and `--payload` are sent as written there, and carry
every field the entitlement keeps.
- `licenses entitlements update` does it whenever `--overage-percent` is absent, whatever
carries the value: `--number`, `--boolean`, an object, or `--file` and `--payload`, which
hold the value and nothing else. The grant keeps its allowance when the new value is a
finite number. `--overage-percent` sets another, and the grant is then not read.

The API numbers the versions of a license itself. `licenses create` has no `--version`,
and on `licenses update` the flag does not set the version: it states the one the license
is expected to have, and the API refuses the update when the license has another.

### Usage metering

Expand Down Expand Up @@ -191,6 +230,9 @@ prompting, so scripts and CI pipelines pass `--yes`:
kaiten customers delete acme --yes
```

Without `--yes` (`-y`), a run that cannot prompt changes nothing and exits with code `2`.
At the prompt, any answer but `y` or `yes` cancels the command, which exits with code `1`.

## Exit codes

Every command reports the kind of failure through its exit code, so a script can
Expand Down
88 changes: 88 additions & 0 deletions internal/cmd/common_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -122,6 +123,93 @@ func TestWriteMessageFallsBackToABareLineForNonStructuredFormats(t *testing.T) {
}
}

// TestYAMLOutputCarriesTheDocumentJSONOutputCarries pins YAML, the default output of every
// command that returns one resource, to the document --output json prints. The API's types
// describe themselves in JSON only, so YAML used to name their fields after the Go ones
// (externalcustomerid) and to print a typed value -- a grant's, a usage counter's -- as an
// empty mapping.
func TestYAMLOutputCarriesTheDocumentJSONOutputCarries(t *testing.T) {
const usagePath = "/instances/sakura-tokyo-prod/entitlements/monthly-orders/usage"
api := givenRoutes(t, map[string]route{
"GET /customers/sakura-tokyo": {status: http.StatusOK, body: `{
"id": "cus_1", "name": "Sakura Tokyo", "slug": "sakura-tokyo", "externalCustomerId": "crm-1024",
"createdAt": "2026-10-01T09:00:00Z", "createdBy": {"id": "usr_1", "name": "Aiko"}}`},
"GET /licenses/premium-v2/entitlements/monthly-orders": {status: http.StatusOK, body: `{
"entitlementSlug": "monthly-orders", "entitlementType": "NUMBER",
"value": {"type": "number", "value": 1000}, "limitCapExceededOveragePercent": 20}`},
"GET " + usagePath: {status: http.StatusOK, body: `{
"entitlementId": "ent_1", "entitlementSlug": "monthly-orders",
"licenseId": "lic_1", "licenseSlug": "premium-v2",
"value": {"type": "number", "value": 730.5}, "limit": {"type": "number", "value": 1000}}`},
})

tests := []struct {
name string
args []string
want string
}{
{
name: "a resource keeps the API's field names",
args: []string{"customers", "get", "sakura-tokyo"},
want: "externalCustomerId: crm-1024",
},
{
name: "a grant keeps its typed value",
args: []string{"licenses", "entitlements", "get", "premium-v2", "monthly-orders"},
want: "limitCapExceededOveragePercent: 20",
},
{
name: "a usage counter keeps its value and its limit",
args: []string{"instances", "usage", "get", "sakura-tokyo-prod", "monthly-orders"},
want: "value: 730.5",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
givenIsolatedConfig(t)
run := func(extra ...string) string {
return runSuccessfully(t, append(append([]string{"--base-url", api.URL}, extra...), test.args...)...)
}

var want any
if err := json.Unmarshal([]byte(run("--output", "json")), &want); err != nil {
t.Fatalf("decode json output error = %v", err)
}

// YAML is what the command prints when no format is selected, too.
for _, selected := range [][]string{nil, {"--output", "yaml"}} {
stdout := run(selected...)
expectOutputContains(t, stdout, test.want)

var decoded any
if err := yaml.Unmarshal([]byte(stdout), &decoded); err != nil {
t.Fatalf("decode yaml output error = %v, stdout = %q", err, stdout)
}
if got := throughJSON(t, decoded); !reflect.DeepEqual(got, want) {
t.Fatalf("yaml output = %v, want the json document %v", got, want)
}
}
})
}
}

// throughJSON re-reads a decoded YAML document the way JSON types it, so that it compares
// with a decoded JSON document: every number a float64.
func throughJSON(t *testing.T, value any) any {
t.Helper()

raw, err := json.Marshal(value)
if err != nil {
t.Fatalf("encode decoded yaml error = %v", err)
}
var out any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode re-encoded yaml error = %v", err)
}
return out
}

// givenCustomerAPI serves the customer and token endpoints the rendering tests exercise.
func givenCustomerAPI(t *testing.T) *httptest.Server {
t.Helper()
Expand Down
10 changes: 9 additions & 1 deletion internal/cmd/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func newComponentsCreateCommand() *cobra.Command {
}
addInputSourceFlags(cmd, &file, &payload, "component")
inline.register(cmd)
inline.registerPreviousComponentID(cmd)
return cmd
}

Expand Down Expand Up @@ -173,10 +174,17 @@ func (f *componentInputFlags) register(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.name, "name", "", "Component name")
cmd.Flags().StringVar(&f.version, "version", "", "Component version")
cmd.Flags().StringVar(&f.description, "description", "", "Component description")
cmd.Flags().StringVar(&f.previousComponentID, "previous-component-id", "", "Previous component ID")
cmd.Flags().StringVar(&f.slug, "slug", "", "Component slug")
}

// registerPreviousComponentID adds --previous-component-id to create only. The link to the
// version a component follows is set once, when it is created: the API's update refuses
// the field (422 UpdateComponent.PreviousComponentNotSettable) and the request the SDK
// builds there does not carry it, so on update the flag was accepted and dropped.
func (f *componentInputFlags) registerPreviousComponentID(cmd *cobra.Command) {
cmd.Flags().StringVar(&f.previousComponentID, "previous-component-id", "", "ID of the component this one is the next version of")
}

func (f *componentInputFlags) changed(cmd *cobra.Command) bool {
return anyFlagChanged(cmd, "name", "version", "description", "previous-component-id", "slug")
}
Expand Down
44 changes: 44 additions & 0 deletions internal/cmd/components_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"net/http"
"strings"
"testing"
)

// TestPreviousComponentIDIsOfferedOnCreateOnly pins --previous-component-id to the command
// where it does something. The link to the version a component follows is set once, at
// creation: the API's update refuses the field, and the request built there never carried
// it, so `components update --previous-component-id` printed the component and exited 0
// with the link untouched. Create still sends it; update no longer takes the flag.
func TestPreviousComponentIDIsOfferedOnCreateOnly(t *testing.T) {
const component = `{"id": "cmp_2", "name": "kitchen-display", "slug": "kitchen-display-1-3-0", "version": "1.3.0"}`
api := givenRoutes(t, map[string]route{
"POST /components": {status: http.StatusCreated, body: component},
"PUT /components/kitchen-display-1-3-0": {status: http.StatusOK, body: component},
})
givenIsolatedConfig(t)

runSuccessfully(t, "--base-url", api.URL, "components", "create",
"--name", "kitchen-display", "--version", "1.3.0", "--previous-component-id", "cmp_1")
runSuccessfully(t, "--base-url", api.URL, "components", "update", "kitchen-display-1-3-0",
"--name", "kitchen-display", "--version", "1.3.1")

calls := api.calls()
expectCalls(t, calls, "POST /components", "PUT /components/kitchen-display-1-3-0")
expectBody(t, calls[0].body, map[string]any{
"name": "kitchen-display", "version": "1.3.0", "previousComponentId": "cmp_1",
})
expectBody(t, calls[1].body, map[string]any{"name": "kitchen-display", "version": "1.3.1"})

code, err := executeForExitCode(t, "--base-url", api.URL, "components", "update", "kitchen-display-1-3-0",
"--name", "kitchen-display", "--version", "1.3.1", "--previous-component-id", "cmp_1")

if err == nil || !strings.Contains(err.Error(), "unknown flag: --previous-component-id") {
t.Fatalf("Execute() error = %v, want --previous-component-id refused as an unknown flag", err)
}
expectExitCode(t, code, exitUsage)
if got := len(api.calls()); got != len(calls) {
t.Fatalf("the API was called %d more times, want the CLI to stop before the request", got-len(calls))
}
}
10 changes: 6 additions & 4 deletions internal/cmd/entitlements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"sync"
"testing"
Expand Down Expand Up @@ -137,7 +138,7 @@ type route struct {
}

// routedAPI is a fake API that answers each "METHOD path" with its route and records every
// call, in order, with its decoded body.
// call, in order, with its query and its decoded body.
type routedAPI struct {
*httptest.Server

Expand All @@ -146,8 +147,9 @@ type routedAPI struct {
}

type recordedCall struct {
call string
body map[string]any
call string
query url.Values
body map[string]any
}

func (a *routedAPI) calls() []recordedCall {
Expand All @@ -170,7 +172,7 @@ func givenRoutes(t *testing.T, routes map[string]route) *routedAPI {
}
}
api.mu.Lock()
api.recorded = append(api.recorded, recordedCall{call: call, body: body})
api.recorded = append(api.recorded, recordedCall{call: call, query: r.URL.Query(), body: body})
api.mu.Unlock()

answer, ok := routes[call]
Expand Down
Loading
Loading