Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5aa4d41
feat(tendlc): add brand and vetting service write methods
kshahbw Aug 18, 2026
d2e787a
fix(tendlc): make TestBrandIDIsPathEscaped actually verify url.PathEs…
kshahbw Aug 18, 2026
fe6bbad
feat(tendlc): add brand create options and per-brandType validation
kshahbw Aug 18, 2026
db9fc29
fix(tendlc): aggregate all violations in brand validation; fix test c…
kshahbw Aug 18, 2026
5218064
feat(tendlc): add lossless brand update builder and identity-change d…
kshahbw Aug 18, 2026
37782e9
fix(tendlc): make the deep-copy test cover nested values, validate br…
kshahbw Aug 18, 2026
e732f7e
feat(tendlc): add terminal-state classifier and receipt-preserving po…
kshahbw Aug 18, 2026
4aeb16e
fix(tendlc): cover isNotFound/fetchBrand and preserve exit code on re…
kshahbw Aug 18, 2026
2785959
feat(tendlc): add band tendlc brand list, get, and history
kshahbw Aug 18, 2026
a841fc9
test(tendlc): cover brand list/history --all page accumulation
kshahbw Aug 18, 2026
b775502
feat(tendlc): add brand create with pre-flight profile check, and bra…
kshahbw Aug 18, 2026
e0ceed0
feat(tendlc): add brand update with identity-change confirm gate, and…
kshahbw Aug 18, 2026
22531ac
feat(tendlc): add brand reverify and resend-2fa
kshahbw Aug 18, 2026
4dcbbf9
feat(tendlc): add band tendlc vetting list, request, and import
kshahbw Aug 19, 2026
c1aaddf
fix(tendlc): treat UNVERIFIED as pending while registering, correct t…
kshahbw Aug 19, 2026
0edc669
docs: document the 10DLC brand and vetting command trees
kshahbw Aug 19, 2026
e491968
docs(tendlc): fix update confirm-gate zero-request claim, drop PR num…
kshahbw Aug 19, 2026
960716c
fix(tendlc): correct stale claims, emit an update receipt, close revi…
kshahbw Aug 19, 2026
bd3c27e
fix(tendlc): honest delete receipt, per-type update validation, doc t…
kshahbw Aug 19, 2026
9cadd26
docs: replace live production identifiers with placeholders
kshahbw Aug 19, 2026
6ee8509
fix(tendlc): brand list filters were silently ignored by the API
kshahbw Aug 20, 2026
e29461c
fix(tendlc): correct two stale brand-list flag references
kshahbw Aug 21, 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
565 changes: 549 additions & 16 deletions AGENTS.md

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ That's it. The CLI validates your credentials, figures out which accounts you ca
```sh
band auth status # see which account is active and what else is available
band auth switch # pick a different account interactively
band auth switch 9901287 # or jump straight to one by ID
band auth switch 9900000 # or jump straight to one by ID
```

You can also pass `--account-id` to any command to override the active account for a single call.
Expand Down Expand Up @@ -267,7 +267,7 @@ band customer-profile list --plain
band customer-profile get <id> --plain
```

Brand and campaign registration still happen in the Bandwidth App. See [AGENTS.md](AGENTS.md) for the full command reference, including update, delete/restore, and version history.
If you register directly with TCR (not through import), brand registration now happens in the CLI too — see `band tendlc brand create` above. Campaign registration still happens in the Bandwidth App either way. See [AGENTS.md](AGENTS.md#customer-profiles) for the full customer-profile command reference, including update, delete/restore, and version history.

### 10DLC campaigns (local numbers)

Expand All @@ -280,7 +280,7 @@ band tendlc number +19195551234 --plain # check a specific number
band tendlc campaigns --plain # list campaigns on your account
```

Campaign and brand registration happen in the Bandwidth Appsee [dev.bandwidth.com](https://dev.bandwidth.com/docs/messaging/campaign-management/) for the full guide. Once you have a campaign, assign numbers to it with `band tnoption assign`.
These two commands are for **import** customers (accounts that register campaigns through TCR and import them to Bandwidth) — campaign registration for them still happens in the Bandwidth App; see [dev.bandwidth.com](https://dev.bandwidth.com/docs/messaging/campaign-management/) for the full guide. **Direct** customers register brands via `band tendlc brand create` (above); campaign registration for direct customers isn't in the CLI yet. Once you have a campaign either way, assign numbers to it with `band tnoption assign`.

### Toll-free verification (toll-free numbers)

Expand Down Expand Up @@ -477,6 +477,25 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `band tnoption get <id>` | Check the status of a TN Option Order |
| `band tnoption list` | List TN Option Orders (filter by `--status`, `--tn`) |

### 10DLC brands and vettings (direct customers)

`band tendlc brand` and `band tendlc vetting` register and manage 10DLC brands for accounts that register directly with TCR (not through import). Requires the Registration Center feature and Campaign Management role — check with `band tendlc status --plain`. A brand needs a customer profile first (`band customer-profile create`); see [AGENTS.md](AGENTS.md#10dlc-brands) for the full flag matrix, `--wait` semantics, and exit codes.

| Command | What it does |
|---------|-------------|
| `band tendlc brand create --customer-profile-id <id> --brand-type <type> ...` | Register a brand (required flags vary by `--brand-type`; `--wait` blocks until VERIFIED/VETTED_VERIFIED) |
| `band tendlc brand list` | List brands (filter by `--customer-profile-id-contains`, `--brand-id-contains`, `--identity-status`, `--brand-type`, name substrings) |
| `band tendlc brand get <id>` | Get one brand — accepts either the `bandwidthId` or the TCR `brandId` |
| `band tendlc brand history <id>` | Show a brand's activity log |
| `band tendlc brand update <id>` | Update a brand (read-modify-write; `--confirm` required when an identity-affecting field changes) |
| `band tendlc brand delete <id>` | Permanently delete a brand (`--confirm` required; does not delete the backing customer profile) |
| `band tendlc brand reverify <id>` | Resubmit a brand for identity verification ($4 fee; `--confirm` required) |
| `band tendlc brand resend-2fa <id>` | Re-send the Auth+ business 2FA verification email |
| `band tendlc brand refresh <id>` | Re-pull a brand's current state from TCR |
| `band tendlc vetting list <brand-id>` | List the external vettings recorded against a brand |
| `band tendlc vetting request <brand-id> --evp <evp> --class <class>` | Order a new external vetting (billable; `--confirm` required) |
| `band tendlc vetting import <brand-id> <vetting-id> --evp <evp>` | Record a vetting already performed outside Bandwidth (not billable, no `--confirm`) |

### SIP trunk authentication

| Command | What it does |
Expand Down
181 changes: 181 additions & 0 deletions cmd/tendlc/async.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package tendlc

import (
"fmt"
"time"

"github.com/spf13/cobra"

"github.com/Bandwidth/cli/internal/cmdutil"
"github.com/Bandwidth/cli/internal/output"
tendlcsvc "github.com/Bandwidth/cli/internal/tendlc"
)

// pollTarget describes what to poll and how to interpret it.
type pollTarget struct {
// Noun names the resource in messages: "brand", "vetting".
Noun string
// Fetch reads the resource. found=false means the API answered 404, which
// is "not readable yet" for creates and "done" for deletes — see
// GoneIsDone. An error is a hard transport or decode failure.
Fetch func() (obj map[string]any, found bool, err error)
// Classify maps a fetched resource to an outcome. Called only when found.
Classify func(obj map[string]any) tendlcsvc.StateClass
// Remediate returns operator-facing next steps for a business-failure
// state. Optional; "" prints nothing.
Remediate func(obj map[string]any) string
// LastSeenStatus extracts a short status string from a fetched object,
// for inclusion in the timeout/error receipt (see awaitTerminal). This is
// what lets a caller who times out see what was last observed instead of
// a receipt frozen at call time.
//
// Optional; nil for polls with nothing meaningful to report — a delete
// poll (GoneIsDone) never reaches this, since a found object there is a
// still-existing resource, not a status to describe. Leave it nil rather
// than returning "": an empty string is treated as "nothing observed
// yet" and is omitted from the receipt, so a real empty status and "not
// wired up" must not be conflated by supplying one where there's nothing
// to say.
LastSeenStatus func(obj map[string]any) string
// GoneIsDone makes a 404 the success condition, for delete polls.
GoneIsDone bool
}

// awaitTerminal polls until the resource settles, then writes stdout itself.
//
// It owns stdout on every path because of the one rule that matters here:
// after a 202 the write may already have succeeded, so the accepted resource's
// ID is the single piece of information that cannot be recovered if this
// command exits without printing it. A wrapped error alone prints no
// structured receipt, which is exactly the failure this function exists to
// prevent — modeled on cmd/quickstart's failWithPartial.
//
// receipt is what gets printed on every non-success outcome. It must already
// contain the ID from the 202 and, where one exists, a resume command.
func awaitTerminal(cmd *cobra.Command, t pollTarget, receipt map[string]any, timeout, interval time.Duration) error {
format, plain := cmdutil.OutputFlags(cmd)

// emitReceipt writes the partial-result receipt. A failure to write it is
// deliberately swallowed in favor of the original error: the caller needs
// to know the operation failed more than it needs to know stdout was
// closed, and returning the write error would bury the real cause.
//
// This prints via output.Stdout, not output.StdoutAuto, deliberately: the
// receipt is a value we built ourselves, not a raw API envelope, so it
// never needs FlattenResponse's envelope-unwrapping. That matters because
// FlattenResponse treats ANY single-key map as a wrapper and unwraps it to
// its bare value — a receipt of just {"bandwidthId": "WABC"} would print
// as the bare string "WABC" instead of a JSON object, which is exactly
// the "no structured receipt" failure this function exists to prevent.

// lastStatus holds the most recent status LastSeenStatus reported, so a
// timeout or mid-poll transport failure can say what was last observed
// instead of printing a receipt frozen at call time. It stays "" for
// targets with no LastSeenStatus (or that never got a successful,
// classifiable fetch), and emitReceipt below leaves it out of the
// receipt entirely in that case rather than adding an empty field.
var lastStatus string

emitReceipt := func() {
if lastStatus != "" {
receipt["lastSeenStatus"] = lastStatus
}
_ = output.Stdout(format, receipt)
}

result, err := cmdutil.Poll(cmdutil.PollConfig{
Context: cmd.Context(),
Interval: interval,
Timeout: timeout,
Check: func() (bool, interface{}, error) {
obj, found, err := t.Fetch()
if err != nil {
return false, nil, err
}
if !found {
// 404: done for a delete poll, not-ready-yet for anything else.
return t.GoneIsDone, nil, nil
}
if t.GoneIsDone {
return false, nil, nil
}
if t.LastSeenStatus != nil {
if s := t.LastSeenStatus(obj); s != "" {
lastStatus = s
}
}
switch t.Classify(obj) {
case tendlcsvc.StateSuccess, tendlcsvc.StateFailure:
return true, obj, nil
default:
return false, nil, nil
}
},
})
if err != nil {
// Timeout, cancellation, or a transport failure after acceptance. All
// three get the receipt: the write may have landed.
emitReceipt()
return err
}

if result == nil {
// A delete poll that found the resource gone. There is no final
// resource to print, so the receipt IS the success output.
emitReceipt()
return nil
}

final, ok := result.(map[string]any)
if !ok {
emitReceipt()
return fmt.Errorf("polling %s returned an unexpected shape (%T)", t.Noun, result)
}

if t.Classify(final) == tendlcsvc.StateFailure {
// final is real API data (not a synthetic receipt), so StdoutAuto's
// flatten-on-plain behavior is correct here — see emitReceipt above
// for why receipts print differently.
//
// The write error is reported but does not replace the return value:
// this is a business failure regardless of whether the write
// succeeded, and returning the bare write error here would drop the
// ConflictError classification (exit 4) along with the remediation
// text below, silently downgrading a failed brand/vetting into
// "something went wrong printing it."
if err := output.StdoutAuto(format, plain, final); err != nil {
cmd.PrintErrln(fmt.Sprintf("writing result: %v", err))
}
msg := fmt.Sprintf("%s did not complete successfully", t.Noun)
if t.Remediate != nil {
if r := t.Remediate(final); r != "" {
msg = r
}
}
cmd.PrintErrln(msg)
return &cmdutil.ConflictError{Message: msg}
}

// final is real API data, not a synthetic receipt — see emitReceipt above
// for why receipts go through output.Stdout instead of StdoutAuto.
return output.StdoutAuto(format, plain, final)
}

// fetchBrand adapts a brand read into pollTarget.Fetch, translating a 404 into
// found=false rather than an error.
func fetchBrand(svc *tendlcsvc.Service, brandID string) func() (map[string]any, bool, error) {
return func() (map[string]any, bool, error) {
env, err := svc.GetBrand(brandID)
if err != nil {
if isNotFound(err) {
return nil, false, nil
}
return nil, false, err
}
obj, err := env.Object()
if err != nil {
return nil, false, err
}
return obj, true, nil
}
}
Loading
Loading