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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ For full flag/argument reference, use `band <command> --help`. This section cove
with more than 500 credentials returns only the first page, and the CLI says so on stderr. `--plain` stdout
stays clean JSON.

### Build SIP Trunks

- **`band sip trunk create --transport udp|tls` opts a Build account in to SIP trunks.** `udp` provisions a UDP trunk; `tls` provisions a UDP trunk and a TLS trunk. An account can have one trunk per transport, so a UDP-only account can still request `tls` later. It is an opt-in because SIP Auth uses a limited number of ports. It lives in the `band sip` group but calls the Build API, not the Dashboard API used by `realm`/`credential` (SIP digest auth).
- **`--transport` is required.** A missing or invalid value exits **6** with no request made.
- **A 202 means accepted, not ready.** `--plain` returns `{accountId, message, state, transport}` with `state: "PROVISIONING"`. There is no status/GET endpoint yet, so there is no `--wait`; do not report the trunks as usable.
- **A 409 exits 4 and is ambiguous:** the account already has a trunk of that transport, a request is already in progress, or account provisioning hasn't finished. Do not retry blindly.
- **There is deliberately no `--if-not-exists`.** The 409 cannot distinguish "exists" from "in progress" or "not ready", and there is no GET to reconcile against. The server rejects duplicates, so a retry cannot double-provision.

### Quickstart

- **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.
Expand Down Expand Up @@ -458,6 +466,8 @@ band auth status # confirm

After calling `band account verify`, stop and tell the user they need to check their email to set a password before generating API credentials. Do not attempt to poll or wait for that step — the next CLI step (`band auth login`) requires credentials that are only available after the human finishes the browser flow.

**SIP trunks are opt-in** through `band sip trunk create --transport udp|tls` (see [Build SIP Trunks](#build-sip-trunks)); they are not part of account provisioning.

**After login, the account already has a voice app and a phone number.** Build accounts ship with both pre-provisioned. Run `band app list --plain` to discover the voice app — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. (`band number list` doesn't work on Build yet; the pre-provisioned number is reachable via the account portal and already wired to the default voice app.)

---
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir
| `band sip credential list --realm <realm>` | List a realm's credentials (pagination is not implemented — a full 500-credential page warns on stderr that the list may be truncated) |
| `band sip credential get <credential-id> --realm <realm>` | Get one credential |
| `band sip credential delete <credential-id> --realm <realm>` | Delete a credential |
| `band sip trunk create --transport udp\|tls` | Opt a Build account in to SIP trunks (`tls` also provisions a UDP trunk; one trunk per transport). Async: success means accepted (`state: PROVISIONING`) and there is no status command yet. A 409 (exit 4) means the trunk exists, a request is in progress, or account provisioning hasn't finished |
| `band sip status` | Probe whether this account can use SIP provisioning (resolves the `unknown` capability from `band auth status`) |

### Porting
Expand Down
3 changes: 3 additions & 0 deletions cmd/sip/sip.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ var Cmd = &cobra.Command{
band sip credential get 870880 --realm vapi --plain
band sip credential delete 870880 --realm vapi --plain

# Build account SIP trunks
band sip trunk create --transport tls --plain

# Status
band sip status --plain`,
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/sip/trunk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sip

import "github.com/spf13/cobra"

func init() { Cmd.AddCommand(trunkCmd) }

var trunkCmd = &cobra.Command{
Use: "trunk",
Short: "Opt a Build account in to SIP trunks",
Long: "Provision the SIP trunks of a Bandwidth Build account through the Build API.",
}
85 changes: 85 additions & 0 deletions cmd/sip/trunk_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package sip

import (
"errors"
"fmt"
"net/url"
"os"
"strings"

"github.com/spf13/cobra"

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

var trunkTransport string

func init() {
trunkCreateCmd.Flags().StringVar(&trunkTransport, "transport", "", "SIP trunk transport: udp or tls (required; tls also provisions a udp trunk)")
trunkCmd.AddCommand(trunkCreateCmd)
}

// trunkClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient).
var trunkClient cmdutil.ClientFunc = func(accountIDOverride string) (api.Requester, string, error) {
return cmdutil.BuildAPIClient(accountIDOverride)
}

var trunkCreateCmd = &cobra.Command{
Use: "create",
Short: "Opt the account in to SIP trunks",
Long: `Starts provisioning SIP trunks for the account. This is an opt-in because SIP Auth uses a limited number of ports.

--transport udp provisions a UDP trunk. --transport tls provisions a UDP trunk and a TLS trunk.
An account can have one trunk per transport, so a UDP-only account can still request tls later.

The request is asynchronous: success means it was accepted (state PROVISIONING), not that the
trunks are ready, and there is no status command yet. A 409 means the account already has a trunk
of that transport, a request is already in progress, or account provisioning has not finished (exit 4).`,
Example: ` band sip trunk create --transport udp
band sip trunk create --transport tls --plain`,
RunE: runTrunkCreate,
}

// normalizeTransport upper-cases and validates a --transport value against the wire enum (UDP, TLS).
func normalizeTransport(raw string) (string, error) {
transport := strings.ToUpper(strings.TrimSpace(raw))
if transport != "UDP" && transport != "TLS" {
return "", cmdutil.NewFlagError("--transport is required and must be one of: udp, tls")
}
return transport, nil
}

func runTrunkCreate(cmd *cobra.Command, args []string) error {
transport, err := normalizeTransport(trunkTransport)
if err != nil {
return err
}

client, acctID, err := trunkClient(cmdutil.AccountIDFlag(cmd))
if err != nil {
return err
}

var result interface{}
path := "/accounts/" + url.PathEscape(acctID) + "/sip-trunks"
if err := client.Post(cmd.Context(), path, map[string]interface{}{"transport": transport}, &result); err != nil {
var apiErr *api.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 409 {
return conflict(err, "SIP trunk request rejected — the account may already have a %s trunk, a request may be in progress, or account provisioning may not be finished; do not retry blindly: %v", transport, err)
}
return fmt.Errorf("requesting SIP trunks: %w", err)
}

format, plain := cmdutil.OutputFlags(cmd)
if err := emit(format, plain, result); err != nil {
return err
}

fmt.Fprintln(os.Stderr)
ui.Successf("SIP trunk request accepted")
ui.Infof("Provisioning is asynchronous and there is no status command yet; this is an acceptance, not a completed trunk.")

return nil
}
109 changes: 109 additions & 0 deletions cmd/sip/trunk_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package sip

import (
"errors"
"testing"

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

// swapTrunkClient substitutes trunkClient with fake, restoring it on cleanup; no t.Parallel() on callers (mutates a global).
func swapTrunkClient(t *testing.T, fake *testutil.FakeClient) {
t.Helper()
orig := trunkClient
t.Cleanup(func() { trunkClient = orig })
trunkClient = func(string) (api.Requester, string, error) {
return fake, "9900001", nil
}
t.Cleanup(func() { trunkTransport = "" })
}

func runTrunkCreateCmd(t *testing.T, args ...string) (string, error) {
t.Helper()
root := testutil.NewTestRoot(trunkCreateCmd)
root.SetArgs(append([]string{"create"}, args...))
var execErr error
out := testutil.CaptureStdout(t, func() { execErr = root.Execute() })
return out, execErr
}

func TestSipTrunkPlainOutput(t *testing.T) {
fake := &testutil.FakeClient{PostResult: map[string]interface{}{
"links": []interface{}{},
"data": map[string]interface{}{
"accountId": "9900001",
"transport": "TLS",
"state": "PROVISIONING",
"message": "SIP trunk provisioning started. The trunks are ready once it completes.",
},
"errors": []interface{}{},
}}
swapTrunkClient(t, fake)

out, err := runTrunkCreateCmd(t, "--transport", "tls", "--plain")
if err != nil {
t.Fatalf("execute: %v", err)
}

want := "{\n \"accountId\": \"9900001\",\n \"message\": \"SIP trunk provisioning started. The trunks are ready once it completes.\",\n \"state\": \"PROVISIONING\",\n \"transport\": \"TLS\"\n}\n"
if out != want {
t.Fatalf("golden mismatch:\n got: %q\nwant: %q", out, want)
}
if fake.PostPath != "/accounts/9900001/sip-trunks" {
t.Errorf("PostPath = %q, want /accounts/9900001/sip-trunks", fake.PostPath)
}
body, ok := fake.PostBody.(map[string]interface{})
if !ok || body["transport"] != "TLS" || len(body) != 1 {
t.Errorf("PostBody = %#v, want {transport: TLS}", fake.PostBody)
}
}

func TestSipTrunkNormalizesTransport(t *testing.T) {
fake := &testutil.FakeClient{PostResult: map[string]interface{}{"data": map[string]interface{}{"state": "PROVISIONING"}}}
swapTrunkClient(t, fake)

if _, err := runTrunkCreateCmd(t, "--transport", " udp ", "--plain"); err != nil {
t.Fatalf("execute: %v", err)
}
body, _ := fake.PostBody.(map[string]interface{})
if body["transport"] != "UDP" {
t.Errorf("transport = %v, want UDP", body["transport"])
}
}

func TestSipTrunkInvalidTransportIsFlagError(t *testing.T) {
for _, args := range [][]string{{}, {"--transport", "tcp"}} {
fake := &testutil.FakeClient{}
swapTrunkClient(t, fake)

_, err := runTrunkCreateCmd(t, args...)
if err == nil {
t.Fatalf("args %v: expected error", args)
}
if code := cmdutil.ExitCodeForError(err); code != cmdutil.ExitFlagError {
t.Errorf("args %v: exit = %d, want %d", args, code, cmdutil.ExitFlagError)
}
if fake.PostPath != "" {
t.Errorf("args %v: unexpected request to %q", args, fake.PostPath)
}
}
}

func TestSipTrunkConflictExitsFour(t *testing.T) {
fake := &testutil.FakeClient{PostErr: &api.APIError{StatusCode: 409, Body: "conflict"}}
swapTrunkClient(t, fake)

_, err := runTrunkCreateCmd(t, "--transport", "udp", "--plain")
if err == nil {
t.Fatal("expected error")
}
var ce *cmdutil.ConflictError
if !errors.As(err, &ce) {
t.Fatalf("error is %T, want *cmdutil.ConflictError", err)
}
if code := cmdutil.ExitCodeForError(err); code != cmdutil.ExitConflict {
t.Errorf("exit = %d, want %d", code, cmdutil.ExitConflict)
}
}
12 changes: 12 additions & 0 deletions internal/cmdutil/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,18 @@ func PlatformClient(accountIDOverride string) (*api.Client, string, error) {
return api.NewClient(apiHostForEnvironment(env), tm), acctID, nil
}

func buildAPIClient(accountIDOverride string) (api.Requester, string, error) {
tm, acctID, env, err := authenticate(accountIDOverride)
if err != nil {
return nil, "", err
}
return api.NewClient(apiHostForEnvironment(env)+"/v1/build", tm), acctID, nil
}

// BuildAPIClient returns an authenticated client for the Bandwidth Build API (/v1/build).
// It is a var so tests can substitute a fake that implements api.Requester.
var BuildAPIClient ClientFunc = buildAPIClient

// InsightsClient returns a JSON client for the Bandwidth Insights API.
// Insights is production-only (single published host), so — like
// MessagingClient — the OAuth token must be minted against the PROD API host:
Expand Down
Loading