diff --git a/AGENTS.md b/AGENTS.md index ebd32e3..f824673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,14 @@ For full flag/argument reference, use `band --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 `. 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. @@ -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.) --- diff --git a/README.md b/README.md index ad6cc4e..3fcee1f 100644 --- a/README.md +++ b/README.md @@ -554,6 +554,7 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir | `band sip credential list --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 --realm ` | Get one credential | | `band sip credential delete --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 diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index f346e70..cf98840 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -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`, } diff --git a/cmd/sip/trunk.go b/cmd/sip/trunk.go new file mode 100644 index 0000000..1a46cb0 --- /dev/null +++ b/cmd/sip/trunk.go @@ -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.", +} diff --git a/cmd/sip/trunk_create.go b/cmd/sip/trunk_create.go new file mode 100644 index 0000000..683bc86 --- /dev/null +++ b/cmd/sip/trunk_create.go @@ -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 +} diff --git a/cmd/sip/trunk_create_test.go b/cmd/sip/trunk_create_test.go new file mode 100644 index 0000000..1cd0a5f --- /dev/null +++ b/cmd/sip/trunk_create_test.go @@ -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) + } +} diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index 95cef4d..91516d0 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -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: