Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
68555a6
ci: enforce gofmt via golangci-lint formatters block and reformat tree
kshahbw May 29, 2026
70b096d
fix(wait): map --wait timeouts to exit code 5 via ErrPollTimeout sent…
kshahbw May 29, 2026
3be4e8f
fix(messaging): route messaging client through environment-aware host
kshahbw May 29, 2026
8ceacba
refactor(cmdutil): make VoiceClient an overridable var returning api.…
kshahbw May 29, 2026
b5e3f99
test: add golden --plain contract tests for call list and recording list
kshahbw May 29, 2026
afd8a54
ci: enforce documented command/flag correctness, drop toothless docs-…
kshahbw May 29, 2026
957370d
fix(quickstart): correct number-order body, scope legacy orders to cr…
kshahbw May 29, 2026
0946541
feat(quickstart): make all resource provisioning idempotent, incl. nu…
kshahbw May 29, 2026
6657e70
fix(quickstart): emit partial state on failed VCP assignment instead …
kshahbw May 29, 2026
7b345b9
docs(quickstart): reconcile agent guidance with new VCP-path idempotency
kshahbw May 29, 2026
f273028
fix(messaging): treat Messaging API as production-only (no test host)
kshahbw May 29, 2026
ddb2632
feat(env): wire --environment flag into host selection; warn that mes…
kshahbw May 29, 2026
b1df550
fix(env): show resolved --environment in the account hint
kshahbw May 29, 2026
11138e3
fix(number): order requires SiteId + ExistingTelephoneNumberOrderType…
kshahbw May 29, 2026
bc2af18
fix(number): release requires DisconnectTelephoneNumberOrderType wrapper
kshahbw May 29, 2026
150c9c7
fix(quickstart): correct order body, ensure sub-account+default locat…
kshahbw May 29, 2026
a4965f9
fix(number): order --wait degrades gracefully when credential can't l…
kshahbw Jun 1, 2026
dcc684c
fix(quickstart): scope VCP-assign retry, preserve timeout signal, cor…
kshahbw Jun 3, 2026
70182e0
ci: bump Go to 1.26.4 to clear stdlib security advisories
kshahbw Jun 3, 2026
11a4f95
fix: address code review (messaging token realm, env validation, numb…
kshahbw Jun 3, 2026
c1eba69
test: extract golden-test scaffolding to internal/testutil
kshahbw Jun 3, 2026
dc1f661
refactor(quickstart): share ensureVoiceApp helper across both paths
kshahbw Jun 3, 2026
cfbf0b3
docs: reflect required --subaccount on number order and VCP-path sub-…
kshahbw Jun 3, 2026
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
34 changes: 2 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
branches: [main]

jobs:
# Doc/flag correctness is enforced as a hard gate by cmd/doccontract_test.go,
# which runs as part of `go test ./...` below.
test:
strategy:
matrix:
Expand Down Expand Up @@ -57,35 +59,3 @@ jobs:

- name: Run govulncheck
run: govulncheck ./...

docs-check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Check for doc updates
run: |
BASE=${{ github.event.pull_request.base.sha }}
HEAD=${{ github.event.pull_request.head.sha }}
CHANGED=$(git diff --name-only "$BASE" "$HEAD")

CODE_CHANGED=false
DOCS_CHANGED=false

# Check if command surface or flags changed
if echo "$CHANGED" | grep -qE '^cmd/|^internal/cmdutil/'; then
CODE_CHANGED=true
fi

# Check if any docs were touched
if echo "$CHANGED" | grep -qE '^README\.md$|^AGENTS\.md$'; then
DOCS_CHANGED=true
fi

if [ "$CODE_CHANGED" = true ] && [ "$DOCS_CHANGED" = false ]; then
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."
fi
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ linters:
default: standard
disable:
- errcheck

formatters:
enable:
- gofmt
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ This is stderr only — it won't break piped output parsing.
| `BW_ENVIRONMENT` | API environment: `prod` (default), `test` |
| `BW_API_URL` | Override API base URL (overrides environment-based default) |
| `BW_VOICE_URL` | Override Voice API base URL (overrides environment-based default) |
| `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. |
| `BW_FORMAT` | Output format override |

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

For `number order`, there is no `--if-not-exists` — check `band number list --plain` first.
`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.

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

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

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

### Quickstart

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

- **`band quickstart` output `status` values** (VCP path only — `--legacy` is not idempotent):
- `complete` — all resources created and number assigned; ready to use.
- `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.
- `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>`.

---

Expand Down Expand Up @@ -324,7 +330,7 @@ band vcp create --name "Agent VCP" --app-id <app-id> --if-not-exists --plain
band number list --plain # 4. check existing numbers
# if no numbers:
band number search --area-code 919 --quantity 1 --plain
band number order <number> --wait # 5. order number
band number order <number> --subaccount <subaccount-id> --wait # 5. order number
band vcp assign <vcp-id> <number> # 6. assign number to VCP
band number activate <number> --voice-inbound --wait # 7. enable inbound voice
```
Expand All @@ -341,7 +347,7 @@ band app create --name "Agent Voice" --type voice --callback-url <url> --if-not-
band number list --plain # 5. check numbers
# if no numbers:
band number search --area-code 919 --quantity 1 --plain
band number order <number> --wait # 6. order number
band number order <number> --subaccount <subaccount-id> --wait # 6. order number
```

### Provision messaging from scratch
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Search for available numbers, then order one:

```sh
band number search --area-code 919 --quantity 1
band number order +19195551234 --wait
band number order +19195551234 --subaccount <subaccount-id> --wait
```

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

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.
Expand Down Expand Up @@ -404,7 +404,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| Command | What it does |
|---------|-------------|
| `band number search` | Search available numbers by area code |
| `band number order <number...>` | Order numbers |
| `band number order <number...> --subaccount <id>` | Order numbers into a sub-account (`--subaccount` required) |
| `band number get <number>` | Get voice config details (including VCP assignment) |
| `band number activate <number...>` | Activate voice/messaging services (e.g. enable inbound) |
| `band number deactivate <number...>` | Deactivate voice/messaging services |
Expand Down Expand Up @@ -467,7 +467,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f

| Command | What it does |
|---------|-------------|
| `band quickstart` | One-command setup: creates app, orders number, wires everything up (use `--legacy` for sub-account path) |
| `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) |
| `band bxml <verb>` | Generate BXML locally (no auth needed) |
| `band version` | Print CLI version |

Expand Down Expand Up @@ -498,6 +498,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `BW_FORMAT` | Default output format |
| `BW_API_URL` | Override the API base URL |
| `BW_VOICE_URL` | Override the Voice API base URL |
| `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. |

---

Expand Down
2 changes: 1 addition & 1 deletion cmd/account/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ After registration, complete account setup in your browser:
4. Go to Account > API Credentials to generate OAuth2 credentials
5. Run "band auth login" with those credentials`,
Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe`,
RunE: runRegister,
RunE: runRegister,
}

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

return output.StdoutAuto(format, plain, result)
}

2 changes: 1 addition & 1 deletion cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"os"
"strings"
"github.com/spf13/cobra"

intauth "github.com/Bandwidth/cli/internal/auth"
"github.com/Bandwidth/cli/internal/cmdutil"
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package auth
import (
"bufio"
"fmt"
"github.com/spf13/cobra"
"os"
"strings"
"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/cmdutil"
"github.com/Bandwidth/cli/internal/config"
Expand Down
48 changes: 48 additions & 0 deletions cmd/call/golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package call

import (
"bytes"
"encoding/json"
"testing"

"github.com/Bandwidth/cli/internal/api"
"github.com/Bandwidth/cli/internal/cmdutil"
"github.com/Bandwidth/cli/internal/testutil"
)

func TestCallListPlainOutput(t *testing.T) {
// Fixture is an API-shaped WRAPPER object ({"calls": [...]}), so a passing
// assertion on got[0]["callId"] proves FlattenResponse stripped the wrapper.
// No t.Parallel(): these tests mutate the global cmdutil.VoiceClient.
orig := cmdutil.VoiceClient
t.Cleanup(func() { cmdutil.VoiceClient = orig })
cmdutil.VoiceClient = func(string) (api.Requester, string, error) {
return &testutil.FakeClient{GetResult: map[string]interface{}{
"calls": []interface{}{
map[string]interface{}{"callId": "c-1", "state": "active"},
},
}}, "acct-123", nil
}

root := testutil.NewTestRoot(listCmd)
root.SetArgs([]string{"list", "--plain"})

out := testutil.CaptureStdout(t, func() {
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})

var got []map[string]interface{}
if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil {
t.Fatalf("plain output is not a JSON array: %q (%v)", out, err)
}
if len(got) != 1 || got[0]["callId"] != "c-1" {
t.Fatalf("flatten/normalize did not produce the expected array: %q", out)
}

want := "[\n {\n \"callId\": \"c-1\",\n \"state\": \"active\"\n }\n]\n"
if out != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
}
}
Loading
Loading