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
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,10 @@ agent with email/web access) to finish.

```bash
band account register --phone +15555550100 --email you@example.com --first-name Jane --last-name Doe --accept-tos
# → registration submitted (POST /v1/build/registrations)
# → registration request received (POST /v1/build/registrations)

band account send-code --phone +15555550100 --email you@example.com --delivery-channel sms # or "voice"
# → verification code sent. Choosing "sms" IS the customer's consent to
# → verification code requested. Choosing "sms" IS the customer's consent to
# receive that one-time code by text — there is no separate flag for it.

# STOP: the code is delivered out-of-band (a text message or phone call to
Expand All @@ -448,6 +448,14 @@ band auth status # confirm
2. Ask the user to review and confirm they accept
3. Only after confirmation, run the command with `--accept-tos`

**Success output does not confirm delivery.** `register` and `send-code` exit 0 when the request is accepted, whether or not an email or code was actually sent. Tell the user a code or email was *requested* and ask them to confirm it arrived; never report it as sent. If the user says nothing arrived after a few minutes:

- Check that `--email` and `--phone` match what they registered with (a mismatch sends nothing), and re-run `band account register` if the registration may have expired.
- If they may already have a Bandwidth account, send them to `band auth login`, or to <https://passport.bandwidth.com/forgot-password> to reset the password.
- Do not loop on `send-code`.

`band account verify` fails with `Code supplied is not valid` for a wrong code **and** for a mismatched `--email`/`--phone`. Check the flags before retrying, and never guess codes.

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.

**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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ Then finish setup in your browser:

Once your credentials are ready, run `band auth login` and you're off.

The CLI confirms that each request was accepted, not that an email or code was actually sent. If nothing arrives, check that `--email` and `--phone` match what you registered with, and run `band account register` again if it's been a while. If you already have a Bandwidth account, run `band auth login` or reset your password at [passport.bandwidth.com/forgot-password](https://passport.bandwidth.com/forgot-password).

**What you get:** Every Build account ships with a voice application and a phone number already provisioned. Run `band auth status` to confirm your account type and capabilities, then `band app list` to see your pre-provisioned voice app — then you're ready to [make a call](#make-a-call). (Your pre-provisioned number is also visible in the Bandwidth App.)

**Build is voice-only.** Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification all require a full Bandwidth account. If you try one of those commands on a Build account, the CLI fails fast (exit code 4) and points you at the upgrade path. For current Build pricing, credit costs, and trial limits, see [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial). [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) when you're ready to upgrade.
Expand Down
131 changes: 131 additions & 0 deletions cmd/account/messages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package account

import (
"errors"
"strings"
"testing"

"github.com/spf13/cobra"

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

// runForStderr executes child against fake and returns what it wrote to stderr
// along with its error. Stdout is captured too so it doesn't leak into test output.
func runForStderr(t *testing.T, fake *testutil.FakeClient, child *cobra.Command, args ...string) (string, error) {
t.Helper()
swapRegistrationClient(t, fake)
root := testutil.NewTestRoot(child)
root.SetArgs(append(args, "--plain"))
var err error
stderr := testutil.CaptureStderr(t, func() {
testutil.CaptureStdout(t, func() { err = root.Execute() })
})
return stderr, err
}

func okResult() *testutil.FakeClient {
return &testutil.FakeClient{PostResult: map[string]interface{}{
"data": map[string]interface{}{"status": "OK"},
"errors": []interface{}{},
}}
}

func assertContains(t *testing.T, got string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(got, want) {
t.Errorf("stderr missing %q\n got: %q", want, got)
}
}
}

func assertNotContains(t *testing.T, got string, unwanted ...string) {
t.Helper()
for _, s := range unwanted {
if strings.Contains(got, s) {
t.Errorf("stderr unexpectedly contains %q\n got: %q", s, got)
}
}
}

// Success only means the request was accepted, so the wording must stay
// conditional and point existing customers somewhere useful.
func TestRegisterOutputIsConditional(t *testing.T) {
stderr, err := runForStderr(t, okResult(), registerCmd,
"register",
"--phone", "+19195551234",
"--email", "user@example.com",
"--first-name", "Jane",
"--last-name", "Doe",
"--accept-tos",
)
if err != nil {
t.Fatalf("execute: %v", err)
}

assertContains(t, stderr,
"If this email can be used to create an account",
"user@example.com",
"band auth login",
"https://passport.bandwidth.com/forgot-password",
)
assertNotContains(t, stderr, "Registration submitted!")
}

func TestSendCodeOutputIsConditional(t *testing.T) {
stderr, err := runForStderr(t, okResult(), sendCodeCmd,
"send-code",
"--phone", "+19195551234",
"--email", "user@example.com",
"--delivery-channel", "sms",
)
if err != nil {
t.Fatalf("execute: %v", err)
}

assertContains(t, stderr,
"If a pending registration matches",
"via sms",
"band account register",
)
assertNotContains(t, stderr, "Verification code sent via")
}

func TestVerifyFailureHint(t *testing.T) {
const hint = "match the ones you registered with"
tests := []struct {
name string
err error
wantHint bool
}{
{name: "rejected code", err: &api.APIError{StatusCode: 400, Body: "Code supplied is not valid."}, wantHint: true},
{name: "server error", err: &api.APIError{StatusCode: 500}, wantHint: false},
{name: "network error", err: errors.New("connection refused"), wantHint: false},
{name: "success", err: nil, wantHint: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := okResult()
fake.PostErr = tt.err

stderr, err := runForStderr(t, fake, verifyCmd,
"verify",
"--phone", "+19195551234",
"--email", "user@example.com",
"--code", "123456",
)

if (err != nil) != (tt.err != nil) {
t.Fatalf("execute error = %v, want error: %v", err, tt.err != nil)
}
if tt.wantHint {
assertContains(t, stderr, hint, "band account register")
} else {
assertNotContains(t, stderr, hint)
}
})
}
}
12 changes: 9 additions & 3 deletions cmd/account/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ var (
registerSmsOptIn bool
)

const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/"
const (
tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/"
forgotPasswordURL = "https://passport.bandwidth.com/forgot-password"
)

// registrationClient is a swappable ClientFunc seam for tests (see cmdutil.VoiceClient); accountIDOverride is unused — no account exists yet.
var registrationClient cmdutil.ClientFunc = func(string) (api.Requester, string, error) {
Expand Down Expand Up @@ -119,14 +122,17 @@ func runRegister(cmd *cobra.Command, args []string) error {
return err
}

// Success only means the request was accepted, so the wording stays conditional.
fmt.Fprintln(os.Stderr)
ui.Successf("Registration submitted!")
ui.Successf("Registration request received.")
ui.Infof("If this email can be used to create an account, a registration link is on its way to %s.", registerEmail)
ui.Infof("Already have a Bandwidth account? Run \"band auth login\", or reset your password at %s", forgotPasswordURL)
ui.Headerf("Next steps:")
ui.Infof("1. Verify your phone number:")
ui.Infof(" band account send-code --phone %s --email %s --delivery-channel sms", registerPhone, registerEmail)
ui.Infof(" band account verify --phone %s --email %s --code <code-you-received>", registerPhone, registerEmail)
ui.Infof(" (pass --delivery-channel voice on send-code for a phone call instead of a text)")
ui.Infof("2. Check your email (%s) for a registration link from Bandwidth to set your password", registerEmail)
ui.Infof("2. If this is a new account, check your email (%s) for a registration link from Bandwidth to set your password", registerEmail)
ui.Infof("3. Go to Account > API Credentials to generate your OAuth2 credentials")
ui.Infof("4. Run: band auth login --client-id <id> --client-secret <secret>")

Expand Down
5 changes: 4 additions & 1 deletion cmd/account/send_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ func runSendCode(cmd *cobra.Command, args []string) error {
return err
}

// Success only means the request was accepted, so the wording stays conditional.
fmt.Fprintln(os.Stderr)
ui.Successf("Verification code sent via %s", strings.ToLower(channel))
ui.Successf("Verification code requested")
ui.Infof("If a pending registration matches this email and phone number, a code is on its way via %s.", strings.ToLower(channel))
ui.Infof("No code? Check that --email and --phone match what you registered with, or run \"band account register\" again.")
ui.Infof("Next: band account verify --phone %s --email %s --code <code-you-received>", sendCodePhone, sendCodeEmail)

return nil
Expand Down
13 changes: 13 additions & 0 deletions cmd/account/verify.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package account

import (
"errors"
"fmt"
"net/http"
"os"

"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"
Expand Down Expand Up @@ -40,6 +43,15 @@ has no CLI equivalent.`,
RunE: runVerify,
}

// hintOnRejectedCode points at the likely causes of a 400, since a mistyped
// --email or --phone is reported the same way as a wrong code.
func hintOnRejectedCode(err error) {
var apiErr *api.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest {
ui.Infof("Check that --email and --phone match the ones you registered with. If it has been a while, run \"band account register\" again.")
}
}

func runVerify(cmd *cobra.Command, args []string) error {
client, _, err := registrationClient("")
if err != nil {
Expand All @@ -54,6 +66,7 @@ func runVerify(cmd *cobra.Command, args []string) error {

var result interface{}
if err := client.Post(cmd.Context(), "/registrations/code/verify", reqBody, &result); err != nil {
hintOnRejectedCode(err)
return fmt.Errorf("verifying phone number: %w", err)
}

Expand Down
21 changes: 18 additions & 3 deletions internal/testutil/golden.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type FakeClient struct {
GetResult interface{}

PostResult interface{}
PostErr error // when set, Post returns it after recording path/body
PostPath string // path from the most recent Post call
PostBody interface{} // body from the most recent Post call
}
Expand All @@ -31,6 +32,9 @@ func (f *FakeClient) Get(_ context.Context, path string, result interface{}) err
func (f *FakeClient) Post(_ context.Context, path string, body, result interface{}) error {
f.PostPath = path
f.PostBody = body
if f.PostErr != nil {
return f.PostErr
}
if result == nil {
return nil
}
Expand Down Expand Up @@ -58,15 +62,26 @@ func NewTestRoot(child *cobra.Command) *cobra.Command {
// CaptureStdout runs fn while capturing everything written to os.Stdout.
func CaptureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
return capture(t, &os.Stdout, fn)
}

// CaptureStderr runs fn while capturing everything written to os.Stderr.
func CaptureStderr(t *testing.T, fn func()) string {
t.Helper()
return capture(t, &os.Stderr, fn)
}

func capture(t *testing.T, target **os.File, fn func()) string {
t.Helper()
orig := *target
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
*target = w
fn()
w.Close()
os.Stdout = orig
*target = orig
out, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
Expand Down
Loading