From dcfba003c37fc4bac09bdc911fa0527ed320b143 Mon Sep 17 00:00:00 2001 From: nhicks Date: Mon, 9 Nov 2026 10:00:00 -0500 Subject: [PATCH 1/2] BUILD-646: Add band build sip-trunk command Wraps POST /v1/build/accounts/{accountId}/sip-trunks (api-specs PR #2696) with a required --transport udp|tls flag. 409 maps to exit 4; provisioning is async so there is no --wait. --- AGENTS.md | 10 ++++ README.md | 8 +++ cmd/build/build.go | 13 +++++ cmd/build/sip_trunk.go | 89 +++++++++++++++++++++++++++++ cmd/build/sip_trunk_test.go | 109 ++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + internal/cmdutil/helpers.go | 12 ++++ 7 files changed, 243 insertions(+) create mode 100644 cmd/build/build.go create mode 100644 cmd/build/sip_trunk.go create mode 100644 cmd/build/sip_trunk_test.go diff --git a/AGENTS.md b/AGENTS.md index ebd32e3..55774ca 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 build sip-trunk --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. This is separate from `band sip 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 build sip-trunk --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..27ed24d 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,14 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band account send-code` | Send (or resend) a phone verification code (`--delivery-channel sms\|voice`, required) | | `band account verify` | Verify a phone number with the code from `send-code` | +### Build account features + +| Command | What it does | +|---------|-------------| +| `band build sip-trunk --transport udp\|tls` | Opt the account in to SIP trunks (`tls` also provisions a UDP trunk; one trunk per transport) | + +SIP trunk provisioning is asynchronous: success means the request was accepted (`state: PROVISIONING`), and there is no status command yet. A 409 (exit 4) means the account already has a trunk of that transport, a request is in progress, or account provisioning hasn't finished. + ### Applications | Command | What it does | diff --git a/cmd/build/build.go b/cmd/build/build.go new file mode 100644 index 0000000..cabbde1 --- /dev/null +++ b/cmd/build/build.go @@ -0,0 +1,13 @@ +// Package build implements the `band build` command group: features of the Bandwidth Build API. +package build + +import "github.com/spf13/cobra" + +// Cmd is the `band build` parent command. +var Cmd = &cobra.Command{ + Use: "build", + Short: "Manage Bandwidth Build account features", + Long: "Manage features of a Bandwidth Build account through the Build API. " + + "SIP realms and credentials for digest authentication live under 'band sip'.", + Example: ` band build sip-trunk --transport TLS --plain`, +} diff --git a/cmd/build/sip_trunk.go b/cmd/build/sip_trunk.go new file mode 100644 index 0000000..6318edc --- /dev/null +++ b/cmd/build/sip_trunk.go @@ -0,0 +1,89 @@ +package build + +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/output" + "github.com/Bandwidth/cli/internal/ui" +) + +var sipTrunkTransport string + +func init() { + sipTrunkCmd.Flags().StringVar(&sipTrunkTransport, "transport", "", "SIP trunk transport: udp or tls (required; tls also provisions a udp trunk)") + Cmd.AddCommand(sipTrunkCmd) +} + +// buildClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient). +var buildClient = func(accountIDOverride string) (api.Requester, string, error) { + return cmdutil.BuildAPIClient(accountIDOverride) +} + +var sipTrunkCmd = &cobra.Command{ + Use: "sip-trunk", + 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 build sip-trunk --transport udp + band build sip-trunk --transport tls --plain`, + RunE: runSipTrunk, +} + +// 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 runSipTrunk(cmd *cobra.Command, args []string) error { + transport, err := normalizeTransport(sipTrunkTransport) + if err != nil { + return err + } + + client, acctID, err := buildClient(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 &cmdutil.ConflictError{ + Message: fmt.Sprintf("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), + Cause: err, + } + } + return fmt.Errorf("requesting SIP trunks: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + if err := output.StdoutAuto(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/build/sip_trunk_test.go b/cmd/build/sip_trunk_test.go new file mode 100644 index 0000000..627795a --- /dev/null +++ b/cmd/build/sip_trunk_test.go @@ -0,0 +1,109 @@ +package build + +import ( + "errors" + "testing" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/testutil" +) + +// swapBuildClient substitutes buildClient with fake, restoring it on cleanup; no t.Parallel() on callers (mutates a global). +func swapBuildClient(t *testing.T, fake *testutil.FakeClient) { + t.Helper() + orig := buildClient + t.Cleanup(func() { buildClient = orig }) + buildClient = func(string) (api.Requester, string, error) { + return fake, "9900001", nil + } + t.Cleanup(func() { sipTrunkTransport = "" }) +} + +func runSipTrunkCmd(t *testing.T, args ...string) (string, error) { + t.Helper() + root := testutil.NewTestRoot(sipTrunkCmd) + root.SetArgs(append([]string{"sip-trunk"}, 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{}{}, + }} + swapBuildClient(t, fake) + + out, err := runSipTrunkCmd(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"}}} + swapBuildClient(t, fake) + + if _, err := runSipTrunkCmd(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{} + swapBuildClient(t, fake) + + _, err := runSipTrunkCmd(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"}} + swapBuildClient(t, fake) + + _, err := runSipTrunkCmd(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/cmd/root.go b/cmd/root.go index da96992..43f0dd9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( accountcmd "github.com/Bandwidth/cli/cmd/account" appcmd "github.com/Bandwidth/cli/cmd/app" authcmd "github.com/Bandwidth/cli/cmd/auth" + buildcmd "github.com/Bandwidth/cli/cmd/build" bxmlcmd "github.com/Bandwidth/cli/cmd/bxml" callcmd "github.com/Bandwidth/cli/cmd/call" customerprofilecmd "github.com/Bandwidth/cli/cmd/customerprofile" @@ -103,6 +104,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&environment, "environment", "", "API environment: prod, test (overrides config)") rootCmd.AddCommand(authcmd.Cmd) rootCmd.AddCommand(accountcmd.Cmd) + rootCmd.AddCommand(buildcmd.Cmd) rootCmd.AddCommand(sitecmd.Cmd) rootCmd.AddCommand(sipcmd.Cmd) rootCmd.AddCommand(locationcmd.Cmd) 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: From 123de7fd32c1d7bb9ad88e97f4d4fbfdd90f3982 Mon Sep 17 00:00:00 2001 From: nhicks Date: Mon, 9 Nov 2026 10:15:44 -0500 Subject: [PATCH 2/2] BUILD-646: Move SIP trunk opt-in under band sip trunk create Drops the new build command group in favor of the existing sip group, matching how Build registration lives under account. --- AGENTS.md | 4 +-- README.md | 9 +---- cmd/build/build.go | 13 ------- cmd/root.go | 2 -- cmd/sip/sip.go | 3 ++ cmd/sip/trunk.go | 11 ++++++ .../sip_trunk.go => sip/trunk_create.go} | 36 +++++++++---------- .../trunk_create_test.go} | 36 +++++++++---------- 8 files changed, 51 insertions(+), 63 deletions(-) delete mode 100644 cmd/build/build.go create mode 100644 cmd/sip/trunk.go rename cmd/{build/sip_trunk.go => sip/trunk_create.go} (67%) rename cmd/{build/sip_trunk_test.go => sip/trunk_create_test.go} (76%) diff --git a/AGENTS.md b/AGENTS.md index 55774ca..f824673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -357,7 +357,7 @@ For full flag/argument reference, use `band --help`. This section cove ### Build SIP Trunks -- **`band build sip-trunk --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. This is separate from `band sip realm`/`credential` (SIP digest auth). +- **`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. @@ -466,7 +466,7 @@ 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 build sip-trunk --transport udp|tls` (see [Build SIP Trunks](#build-sip-trunks)); they are not part of account provisioning. +**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 27ed24d..3fcee1f 100644 --- a/README.md +++ b/README.md @@ -398,14 +398,6 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band account send-code` | Send (or resend) a phone verification code (`--delivery-channel sms\|voice`, required) | | `band account verify` | Verify a phone number with the code from `send-code` | -### Build account features - -| Command | What it does | -|---------|-------------| -| `band build sip-trunk --transport udp\|tls` | Opt the account in to SIP trunks (`tls` also provisions a UDP trunk; one trunk per transport) | - -SIP trunk provisioning is asynchronous: success means the request was accepted (`state: PROVISIONING`), and there is no status command yet. A 409 (exit 4) means the account already has a trunk of that transport, a request is in progress, or account provisioning hasn't finished. - ### Applications | Command | What it does | @@ -562,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/build/build.go b/cmd/build/build.go deleted file mode 100644 index cabbde1..0000000 --- a/cmd/build/build.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package build implements the `band build` command group: features of the Bandwidth Build API. -package build - -import "github.com/spf13/cobra" - -// Cmd is the `band build` parent command. -var Cmd = &cobra.Command{ - Use: "build", - Short: "Manage Bandwidth Build account features", - Long: "Manage features of a Bandwidth Build account through the Build API. " + - "SIP realms and credentials for digest authentication live under 'band sip'.", - Example: ` band build sip-trunk --transport TLS --plain`, -} diff --git a/cmd/root.go b/cmd/root.go index 43f0dd9..da96992 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,7 +22,6 @@ import ( accountcmd "github.com/Bandwidth/cli/cmd/account" appcmd "github.com/Bandwidth/cli/cmd/app" authcmd "github.com/Bandwidth/cli/cmd/auth" - buildcmd "github.com/Bandwidth/cli/cmd/build" bxmlcmd "github.com/Bandwidth/cli/cmd/bxml" callcmd "github.com/Bandwidth/cli/cmd/call" customerprofilecmd "github.com/Bandwidth/cli/cmd/customerprofile" @@ -104,7 +103,6 @@ func init() { rootCmd.PersistentFlags().StringVar(&environment, "environment", "", "API environment: prod, test (overrides config)") rootCmd.AddCommand(authcmd.Cmd) rootCmd.AddCommand(accountcmd.Cmd) - rootCmd.AddCommand(buildcmd.Cmd) rootCmd.AddCommand(sitecmd.Cmd) rootCmd.AddCommand(sipcmd.Cmd) rootCmd.AddCommand(locationcmd.Cmd) 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/build/sip_trunk.go b/cmd/sip/trunk_create.go similarity index 67% rename from cmd/build/sip_trunk.go rename to cmd/sip/trunk_create.go index 6318edc..683bc86 100644 --- a/cmd/build/sip_trunk.go +++ b/cmd/sip/trunk_create.go @@ -1,4 +1,4 @@ -package build +package sip import ( "errors" @@ -11,24 +11,23 @@ import ( "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" - "github.com/Bandwidth/cli/internal/output" "github.com/Bandwidth/cli/internal/ui" ) -var sipTrunkTransport string +var trunkTransport string func init() { - sipTrunkCmd.Flags().StringVar(&sipTrunkTransport, "transport", "", "SIP trunk transport: udp or tls (required; tls also provisions a udp trunk)") - Cmd.AddCommand(sipTrunkCmd) + trunkCreateCmd.Flags().StringVar(&trunkTransport, "transport", "", "SIP trunk transport: udp or tls (required; tls also provisions a udp trunk)") + trunkCmd.AddCommand(trunkCreateCmd) } -// buildClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient). -var buildClient = func(accountIDOverride string) (api.Requester, string, error) { +// 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 sipTrunkCmd = &cobra.Command{ - Use: "sip-trunk", +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. @@ -38,9 +37,9 @@ An account can have one trunk per transport, so a UDP-only account can still req 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 build sip-trunk --transport udp - band build sip-trunk --transport tls --plain`, - RunE: runSipTrunk, + 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). @@ -52,13 +51,13 @@ func normalizeTransport(raw string) (string, error) { return transport, nil } -func runSipTrunk(cmd *cobra.Command, args []string) error { - transport, err := normalizeTransport(sipTrunkTransport) +func runTrunkCreate(cmd *cobra.Command, args []string) error { + transport, err := normalizeTransport(trunkTransport) if err != nil { return err } - client, acctID, err := buildClient(cmdutil.AccountIDFlag(cmd)) + client, acctID, err := trunkClient(cmdutil.AccountIDFlag(cmd)) if err != nil { return err } @@ -68,16 +67,13 @@ func runSipTrunk(cmd *cobra.Command, args []string) error { 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 &cmdutil.ConflictError{ - Message: fmt.Sprintf("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), - Cause: err, - } + 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 := output.StdoutAuto(format, plain, result); err != nil { + if err := emit(format, plain, result); err != nil { return err } diff --git a/cmd/build/sip_trunk_test.go b/cmd/sip/trunk_create_test.go similarity index 76% rename from cmd/build/sip_trunk_test.go rename to cmd/sip/trunk_create_test.go index 627795a..1cd0a5f 100644 --- a/cmd/build/sip_trunk_test.go +++ b/cmd/sip/trunk_create_test.go @@ -1,4 +1,4 @@ -package build +package sip import ( "errors" @@ -9,21 +9,21 @@ import ( "github.com/Bandwidth/cli/internal/testutil" ) -// swapBuildClient substitutes buildClient with fake, restoring it on cleanup; no t.Parallel() on callers (mutates a global). -func swapBuildClient(t *testing.T, fake *testutil.FakeClient) { +// 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 := buildClient - t.Cleanup(func() { buildClient = orig }) - buildClient = func(string) (api.Requester, string, error) { + orig := trunkClient + t.Cleanup(func() { trunkClient = orig }) + trunkClient = func(string) (api.Requester, string, error) { return fake, "9900001", nil } - t.Cleanup(func() { sipTrunkTransport = "" }) + t.Cleanup(func() { trunkTransport = "" }) } -func runSipTrunkCmd(t *testing.T, args ...string) (string, error) { +func runTrunkCreateCmd(t *testing.T, args ...string) (string, error) { t.Helper() - root := testutil.NewTestRoot(sipTrunkCmd) - root.SetArgs(append([]string{"sip-trunk"}, args...)) + root := testutil.NewTestRoot(trunkCreateCmd) + root.SetArgs(append([]string{"create"}, args...)) var execErr error out := testutil.CaptureStdout(t, func() { execErr = root.Execute() }) return out, execErr @@ -40,9 +40,9 @@ func TestSipTrunkPlainOutput(t *testing.T) { }, "errors": []interface{}{}, }} - swapBuildClient(t, fake) + swapTrunkClient(t, fake) - out, err := runSipTrunkCmd(t, "--transport", "tls", "--plain") + out, err := runTrunkCreateCmd(t, "--transport", "tls", "--plain") if err != nil { t.Fatalf("execute: %v", err) } @@ -62,9 +62,9 @@ func TestSipTrunkPlainOutput(t *testing.T) { func TestSipTrunkNormalizesTransport(t *testing.T) { fake := &testutil.FakeClient{PostResult: map[string]interface{}{"data": map[string]interface{}{"state": "PROVISIONING"}}} - swapBuildClient(t, fake) + swapTrunkClient(t, fake) - if _, err := runSipTrunkCmd(t, "--transport", " udp ", "--plain"); err != nil { + if _, err := runTrunkCreateCmd(t, "--transport", " udp ", "--plain"); err != nil { t.Fatalf("execute: %v", err) } body, _ := fake.PostBody.(map[string]interface{}) @@ -76,9 +76,9 @@ func TestSipTrunkNormalizesTransport(t *testing.T) { func TestSipTrunkInvalidTransportIsFlagError(t *testing.T) { for _, args := range [][]string{{}, {"--transport", "tcp"}} { fake := &testutil.FakeClient{} - swapBuildClient(t, fake) + swapTrunkClient(t, fake) - _, err := runSipTrunkCmd(t, args...) + _, err := runTrunkCreateCmd(t, args...) if err == nil { t.Fatalf("args %v: expected error", args) } @@ -93,9 +93,9 @@ func TestSipTrunkInvalidTransportIsFlagError(t *testing.T) { func TestSipTrunkConflictExitsFour(t *testing.T) { fake := &testutil.FakeClient{PostErr: &api.APIError{StatusCode: 409, Body: "conflict"}} - swapBuildClient(t, fake) + swapTrunkClient(t, fake) - _, err := runSipTrunkCmd(t, "--transport", "udp", "--plain") + _, err := runTrunkCreateCmd(t, "--transport", "udp", "--plain") if err == nil { t.Fatal("expected error") }