Skip to content

Commit 34a6b48

Browse files
authored
Merge pull request #11 from Bandwidth/feat/cli-hardening
Harden agent-facing contracts + fix live-broken Numbers/quickstart commands
2 parents ec0b440 + f544495 commit 34a6b48

40 files changed

Lines changed: 1105 additions & 253 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ on:
77
branches: [main]
88

99
jobs:
10+
# Doc/flag correctness is enforced as a hard gate by cmd/doccontract_test.go,
11+
# which runs as part of `go test ./...` below.
1012
test:
1113
strategy:
1214
matrix:
@@ -57,35 +59,3 @@ jobs:
5759

5860
- name: Run govulncheck
5961
run: govulncheck ./...
60-
61-
docs-check:
62-
if: github.event_name == 'pull_request'
63-
runs-on: ubuntu-latest
64-
steps:
65-
- name: Checkout
66-
uses: actions/checkout@v6
67-
with:
68-
fetch-depth: 0
69-
70-
- name: Check for doc updates
71-
run: |
72-
BASE=${{ github.event.pull_request.base.sha }}
73-
HEAD=${{ github.event.pull_request.head.sha }}
74-
CHANGED=$(git diff --name-only "$BASE" "$HEAD")
75-
76-
CODE_CHANGED=false
77-
DOCS_CHANGED=false
78-
79-
# Check if command surface or flags changed
80-
if echo "$CHANGED" | grep -qE '^cmd/|^internal/cmdutil/'; then
81-
CODE_CHANGED=true
82-
fi
83-
84-
# Check if any docs were touched
85-
if echo "$CHANGED" | grep -qE '^README\.md$|^AGENTS\.md$'; then
86-
DOCS_CHANGED=true
87-
fi
88-
89-
if [ "$CODE_CHANGED" = true ] && [ "$DOCS_CHANGED" = false ]; then
90-
echo "::warning::Command code changed without documentation updates. If this PR adds, removes, or changes commands/flags, please update README.md and/or AGENTS.md."
91-
fi

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ linters:
44
default: standard
55
disable:
66
- errcheck
7+
8+
formatters:
9+
enable:
10+
- gofmt

AGENTS.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ This is stderr only — it won't break piped output parsing.
7979
| `BW_ENVIRONMENT` | API environment: `prod` (default), `test` |
8080
| `BW_API_URL` | Override API base URL (overrides environment-based default) |
8181
| `BW_VOICE_URL` | Override Voice API base URL (overrides environment-based default) |
82+
| `BW_MESSAGING_URL` | Override Messaging API base URL. Messaging is production-only — `--environment test` does NOT change the host (no test messaging endpoint exists); only this override does. |
8283
| `BW_FORMAT` | Output format override |
8384

8485
**Config file location:** `~/.config/band/config.json` (XDG). Falls back to `~/.band/config.json` if the XDG path doesn't exist.
@@ -132,7 +133,7 @@ band app create --name "My App" --type voice --callback-url <url> --if-not-exist
132133
band vcp create --name "My VCP" --if-not-exists
133134
```
134135

135-
For `number order`, there is no `--if-not-exists` — check `band number list --plain` first.
136+
`number order` requires `--subaccount <id>` (the orders API needs a sub-account to order into; see `band subaccount list`). There is no `--if-not-exists` — check `band number list --plain` first.
136137

137138
All read operations (gets, lists, deletes) are safe to retry.
138139

@@ -141,7 +142,7 @@ All read operations (gets, lists, deletes) are safe to retry.
141142
Use `--wait` to block until completion:
142143

143144
```bash
144-
band number order +19195551234 --wait # blocks until number is active (30s default)
145+
band number order +19195551234 --subaccount <subaccount-id> --wait # blocks until number is active (30s default)
145146
band call create --from ... --to ... --wait --timeout 120 # blocks until call completes
146147
band transcription create <call-id> <rec-id> --wait # blocks until transcription ready (60s default)
147148
```
@@ -202,7 +203,12 @@ For full flag/argument reference, use `band <command> --help`. This section cove
202203

203204
### Quickstart
204205

205-
- **Agents should not use `band quickstart`.** It creates real resources that cost money (orders a phone number), doesn't support `--if-not-exists` (running it twice creates duplicate resources and orders a second number), doesn't return structured output for each step, and can't be partially retried if it fails midway. Use the step-by-step provisioning workflows in the [Agent Workflows](#agent-workflows) section instead.
206+
- **Agents should prefer the step-by-step provisioning workflows over `band quickstart`.** Quickstart creates real resources that cost money (it orders a phone number). The default (VCP) path is idempotent — re-running reuses existing resources via find-or-create and will not order a second number — and on failure it prints the resource IDs created so far (`status: partial`, see below). Re-running reuses the app/VCP/sub-account/location — but a number that was ordered and then failed to assign to the VCP is NOT auto-reassigned; finish it with `band vcp assign <vcp-id> <number>`. The `--legacy` path is NOT idempotent (re-running it may order an additional number). Because quickstart bundles several steps behind one command, prefer the step-by-step provisioning workflows in the [Agent Workflows](#agent-workflows) section when you need per-step structured output or fine-grained control.
207+
208+
- **`band quickstart` output `status` values** (VCP path only — `--legacy` is not idempotent):
209+
- `complete` — all resources created and number assigned; ready to use.
210+
- `complete_no_number` — resources created but no number was available in the requested area code; re-run with `--area-code` to try a different code.
211+
- `partial` — quickstart stopped after a failure but printed the resource IDs it created so far (app, VCP, sub-account, location, and possibly an ordered phone number). Re-running reuses the app/VCP/sub-account/location via idempotency checks. **Caveat:** if a number was ordered but its VCP assignment failed, the number is printed under `phoneNumber` but is NOT auto-reassigned on re-run (a re-run would order a *new* number) — finish the existing one with `band vcp assign <vcp-id> <phoneNumber>`.
206212

207213
---
208214

@@ -324,7 +330,7 @@ band vcp create --name "Agent VCP" --app-id <app-id> --if-not-exists --plain
324330
band number list --plain # 4. check existing numbers
325331
# if no numbers:
326332
band number search --area-code 919 --quantity 1 --plain
327-
band number order <number> --wait # 5. order number
333+
band number order <number> --subaccount <subaccount-id> --wait # 5. order number
328334
band vcp assign <vcp-id> <number> # 6. assign number to VCP
329335
band number activate <number> --voice-inbound --wait # 7. enable inbound voice
330336
```
@@ -341,7 +347,7 @@ band app create --name "Agent Voice" --type voice --callback-url <url> --if-not-
341347
band number list --plain # 5. check numbers
342348
# if no numbers:
343349
band number search --area-code 919 --quantity 1 --plain
344-
band number order <number> --wait # 6. order number
350+
band number order <number> --subaccount <subaccount-id> --wait # 6. order number
345351
```
346352

347353
### Provision messaging from scratch

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Search for available numbers, then order one:
138138

139139
```sh
140140
band number search --area-code 919 --quantity 1
141-
band number order +19195551234 --wait
141+
band number order +19195551234 --subaccount <subaccount-id> --wait
142142
```
143143

144144
The `--wait` flag blocks until the number is active, so you don't have to poll.
@@ -294,7 +294,7 @@ A fresh UP account typically has one sub-account and one location already create
294294
```sh
295295
band number list # list your numbers
296296
band number search --area-code 919 --quantity 5 # search available numbers
297-
band number order +19195551234 --wait # order (blocks until active)
297+
band number order +19195551234 --subaccount <subaccount-id> --wait # order (blocks until active)
298298
band number activate +19195551234 --voice-inbound --wait # turn on inbound voice
299299
band number release +19195551234 # release a number
300300
```
@@ -347,7 +347,7 @@ band subaccount create --name "My Subaccount"
347347
band location create --subaccount <subaccount-id> --name "My Location"
348348
band app create --name "My Voice App" --type voice --callback-url https://your-server.example.com/callbacks
349349
band number search --area-code 919 --quantity 1
350-
band number order +19195551234 --wait
350+
band number order +19195551234 --subaccount <subaccount-id> --wait
351351
```
352352

353353
Sub-accounts (formerly known as sites) are the top-level container. Locations (formerly known as SIP peers) sit inside sub-accounts and define where numbers get routed. The flow is: sub-account → location → application → number.
@@ -404,7 +404,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
404404
| Command | What it does |
405405
|---------|-------------|
406406
| `band number search` | Search available numbers by area code |
407-
| `band number order <number...>` | Order numbers |
407+
| `band number order <number...> --subaccount <id>` | Order numbers into a sub-account (`--subaccount` required) |
408408
| `band number get <number>` | Get voice config details (including VCP assignment) |
409409
| `band number activate <number...>` | Activate voice/messaging services (e.g. enable inbound) |
410410
| `band number deactivate <number...>` | Deactivate voice/messaging services |
@@ -467,7 +467,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
467467

468468
| Command | What it does |
469469
|---------|-------------|
470-
| `band quickstart` | One-command setup: creates app, orders number, wires everything up (use `--legacy` for sub-account path) |
470+
| `band quickstart` | One-command setup: provisions an app + VCP + sub-account/location, orders a number, and assigns it (`--legacy` uses the pre-VCP provisioning path) |
471471
| `band bxml <verb>` | Generate BXML locally (no auth needed) |
472472
| `band version` | Print CLI version |
473473

@@ -498,6 +498,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
498498
| `BW_FORMAT` | Default output format |
499499
| `BW_API_URL` | Override the API base URL |
500500
| `BW_VOICE_URL` | Override the Voice API base URL |
501+
| `BW_MESSAGING_URL` | Override the Messaging API base URL. Messaging is production-only (no test host), so `--environment`/`BW_ENVIRONMENT` does not change it; use this for local proxies or the internal lab. |
501502

502503
---
503504

cmd/account/register.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ After registration, complete account setup in your browser:
4949
4. Go to Account > API Credentials to generate OAuth2 credentials
5050
5. Run "band auth login" with those credentials`,
5151
Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe`,
52-
RunE: runRegister,
52+
RunE: runRegister,
5353
}
5454

5555
func runRegister(cmd *cobra.Command, args []string) error {

cmd/app/create.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,13 @@ func runCreate(cmd *cobra.Command, args []string) error {
107107
var result interface{}
108108
if err := client.Post(fmt.Sprintf("/accounts/%s/applications", acctID), api.XMLBody{RootElement: "Application", Data: bodyData}, &result); err != nil {
109109
if strings.Contains(err.Error(), "HTTP voice feature is required") {
110-
return fmt.Errorf("creating voice application: this account requires the HTTP Voice feature to be enabled.\n"+
111-
"Contact Bandwidth support to enable it, or check if your account is on the Universal Platform.\n"+
112-
"If you already have VCPs configured, you may need to link a voice app to them via:\n"+
110+
return fmt.Errorf("creating voice application: this account requires the HTTP Voice feature to be enabled.\n" +
111+
"Contact Bandwidth support to enable it, or check if your account is on the Universal Platform.\n" +
112+
"If you already have VCPs configured, you may need to link a voice app to them via:\n" +
113113
" band vcp create --name <name> --app-id <voice-app-id>")
114114
}
115115
return fmt.Errorf("creating application: %w", err)
116116
}
117117

118118
return output.StdoutAuto(format, plain, result)
119119
}
120-

cmd/auth/login.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import (
55
"encoding/base64"
66
"encoding/json"
77
"fmt"
8+
"github.com/spf13/cobra"
89
"os"
910
"strings"
10-
"github.com/spf13/cobra"
1111

1212
intauth "github.com/Bandwidth/cli/internal/auth"
1313
"github.com/Bandwidth/cli/internal/cmdutil"

cmd/auth/switch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package auth
33
import (
44
"bufio"
55
"fmt"
6+
"github.com/spf13/cobra"
67
"os"
78
"strings"
8-
"github.com/spf13/cobra"
99

1010
"github.com/Bandwidth/cli/internal/cmdutil"
1111
"github.com/Bandwidth/cli/internal/config"

cmd/call/golden_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package call
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/Bandwidth/cli/internal/api"
9+
"github.com/Bandwidth/cli/internal/cmdutil"
10+
"github.com/Bandwidth/cli/internal/testutil"
11+
)
12+
13+
func TestCallListPlainOutput(t *testing.T) {
14+
// Fixture is an API-shaped WRAPPER object ({"calls": [...]}), so a passing
15+
// assertion on got[0]["callId"] proves FlattenResponse stripped the wrapper.
16+
// No t.Parallel(): these tests mutate the global cmdutil.VoiceClient.
17+
orig := cmdutil.VoiceClient
18+
t.Cleanup(func() { cmdutil.VoiceClient = orig })
19+
cmdutil.VoiceClient = func(string) (api.Requester, string, error) {
20+
return &testutil.FakeClient{GetResult: map[string]interface{}{
21+
"calls": []interface{}{
22+
map[string]interface{}{"callId": "c-1", "state": "active"},
23+
},
24+
}}, "acct-123", nil
25+
}
26+
27+
root := testutil.NewTestRoot(listCmd)
28+
root.SetArgs([]string{"list", "--plain"})
29+
30+
out := testutil.CaptureStdout(t, func() {
31+
if err := root.Execute(); err != nil {
32+
t.Fatalf("execute: %v", err)
33+
}
34+
})
35+
36+
var got []map[string]interface{}
37+
if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil {
38+
t.Fatalf("plain output is not a JSON array: %q (%v)", out, err)
39+
}
40+
if len(got) != 1 || got[0]["callId"] != "c-1" {
41+
t.Fatalf("flatten/normalize did not produce the expected array: %q", out)
42+
}
43+
44+
want := "[\n {\n \"callId\": \"c-1\",\n \"state\": \"active\"\n }\n]\n"
45+
if out != want {
46+
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
47+
}
48+
}

0 commit comments

Comments
 (0)