diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5afde80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build + run: go build -o band ./cmd/band + + - name: Test + run: go test ./... -v + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + + security: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + + docs-check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for doc updates + run: | + BASE=${{ github.event.pull_request.base.sha }} + HEAD=${{ github.event.pull_request.head.sha }} + CHANGED=$(git diff --name-only "$BASE" "$HEAD") + + CODE_CHANGED=false + DOCS_CHANGED=false + + # Check if command surface or flags changed + if echo "$CHANGED" | grep -qE '^cmd/|^internal/cmdutil/'; then + CODE_CHANGED=true + fi + + # Check if any docs were touched + if echo "$CHANGED" | grep -qE '^README\.md$|^AGENTS\.md$'; then + DOCS_CHANGED=true + fi + + if [ "$CODE_CHANGED" = true ] && [ "$DOCS_CHANGED" = false ]; then + echo "::warning::Command code changed without documentation updates. If this PR adds, removes, or changes commands/flags, please update README.md and/or AGENTS.md." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a1cd39d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... -v + + release: + needs: [test] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7f0ae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# General macOS +.DS_Store + +# Local test helpers +bxml_server.py +callback_server.py + +# Go build output +/band +dist/ +*.test + +# Documentation (generated) +docs/ + +# IDE/tooling +.serena/ +.claude/ +.obsidian/ +.worktrees diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2841fc4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +version: "2" + +linters: + default: standard + disable: + - errcheck diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..748f882 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,79 @@ +version: 2 + +builds: + - main: ./cmd/band + binary: band + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/Bandwidth/cli/cmd.version={{.Version}} + +archives: + - format: tar.gz + name_template: "band_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +dockers: + - image_templates: + - "ghcr.io/bandwidth/cli:{{ .Version }}-amd64" + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + goarch: amd64 + dockerfile: Dockerfile.goreleaser + - image_templates: + - "ghcr.io/bandwidth/cli:{{ .Version }}-arm64" + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + goarch: arm64 + dockerfile: Dockerfile.goreleaser + +docker_manifests: + - name_template: "ghcr.io/bandwidth/cli:{{ .Version }}" + image_templates: + - "ghcr.io/bandwidth/cli:{{ .Version }}-amd64" + - "ghcr.io/bandwidth/cli:{{ .Version }}-arm64" + - name_template: "ghcr.io/bandwidth/cli:latest" + image_templates: + - "ghcr.io/bandwidth/cli:{{ .Version }}-amd64" + - "ghcr.io/bandwidth/cli:{{ .Version }}-arm64" + +brews: + - repository: + owner: Bandwidth + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + pull_request: + enabled: true + base: + owner: Bandwidth + name: homebrew-tap + branch: main + homepage: "https://github.com/Bandwidth/cli" + description: "Bandwidth CLI — manage voice, messaging, numbers, and more from the command line" + license: "MIT" + install: | + bin.install "band" + test: | + system "#{bin}/band", "version" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5597f33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,695 @@ +# Bandwidth CLI — Agent Reference + +> Structured reference for AI agents using the `band` CLI. +> Covers command semantics, dependency chains, idempotency, polling patterns, and limitations. + +> **Note:** This document is self-contained so agents can operate from a single file. Some content overlaps with README.md by design — auth, exit codes, env vars, and error patterns are duplicated here so an agent never needs to cross-reference. + +## Design Principles + +These principles guide how the CLI is built. If you're contributing changes, maintain them: + +- **`--plain` output must be stable and parseable.** Agents depend on flat JSON. Don't change the shape of `--plain` output without a migration path. +- **`--if-not-exists` for idempotency.** Any create command should support this flag so agents can retry safely. +- **`--wait` for async operations.** Agents can't poll — give them a way to block until the operation completes. +- **Structured exit codes.** Agents use exit codes for control flow, not string parsing. See [Exit Codes](#exit-codes). +- **Update this file.** If you add, remove, or change a command, update this file alongside the README. + +## Scope + +This CLI handles **provisioning, one-shot API operations, and state queries** against Bandwidth's platform. It can set up accounts, manage infrastructure, initiate calls, send messages, and retrieve results. + +It **cannot** receive or respond to mid-call webhook callbacks or message delivery callbacks. An agent can start a call or send a message and later check metadata, but cannot dynamically control the conversation during a call. Real-time call control requires a separate callback-handling server. Message delivery status arrives via webhooks on your application — there is no polling endpoint. + +## Authentication + +The CLI uses OAuth2 client credentials. An agent can bootstrap itself without human interaction: + +```bash +BW_CLIENT_ID= BW_CLIENT_SECRET= band auth login +``` + +Login exchanges the credentials for a token, extracts accessible accounts from the JWT, and stores everything. If the credentials access multiple accounts, the first is selected by default. Override with `--account-id` on any command. + +```bash +band auth switch # change active account (no re-auth needed) +band auth status # verify auth state +``` + +### Credential Profiles + +Store multiple credential sets under named profiles — useful when different roles or environments require different client credentials: + +```bash +band auth login --profile admin # store credentials under "admin" profile +band auth profiles # list all stored profiles +band auth use admin # switch the active profile +``` + +If a credential's `acct_scope` is "All" (system-scope), it can access any account but the CLI will show guidance about passing `--account-id`. Always pass `--account-id` explicitly with system-scope credentials. + +### Account Hint + +When multiple accounts or profiles are active, commands write a hint to stderr so you know which account is being targeted: + +``` +[account: 9901287 | profile: admin | env: test] +``` + +The environment is included in the hint only when credentials span multiple environments or the active environment is non-default. Single-environment users (e.g. customers on prod only) won't see it. + +This is stderr only — it won't break piped output parsing. + +**Environment variables for headless operation:** + +| Variable | Purpose | +|----------|---------| +| `BW_CLIENT_ID` | OAuth2 client ID | +| `BW_CLIENT_SECRET` | OAuth2 client secret | +| `BW_ACCOUNT_ID` | Override active account | +| `BW_ENVIRONMENT` | API environment: `prod` (default), `test` | +| `BW_API_URL` | Override API base URL (overrides environment-based default) | +| `BW_VOICE_URL` | Override Voice API base URL (overrides environment-based default) | +| `BW_FORMAT` | Output format override | + +**Config file location:** `~/.config/band/config.json` (XDG). Falls back to `~/.band/config.json` if the XDG path doesn't exist. + +## Voice Provisioning: Two Paths + +Bandwidth has two provisioning models. **Try the Universal Platform path first.** If it fails with a 403 or "Forbidden" error, fall back to the legacy path. If both fail, the account may not be contracted for voice — contact Bandwidth support. + +### Universal Platform (default) + +Uses Voice Configuration Packages (VCPs). Simpler — no site/location hierarchy needed for voice. + +``` +auth login + └─→ app create (voice application with callback URL) + └─→ vcp create (links to app via --app-id) + └─→ number search → number order + └─→ vcp assign (attach numbers to VCP) + └─→ call create (requires --from, --app-id, --answer-url) +``` + +### Legacy + +Uses the sub-account → location → application chain. Required for accounts not on the Universal Platform. + +``` +auth login + └─→ subaccount create + └─→ location create (requires --site) + └─→ app create (voice application with callback URL) + └─→ number search → number order + └─→ call create (requires --from, --app-id, --answer-url) +``` + +### How to detect which path to use + +1. Try `band vcp list --plain`. If it succeeds → Universal Platform, use VCPs. +2. If it returns exit code 2 (403 Forbidden) → either legacy account or missing VCP role. +3. Try `band app create --type voice ...`. If it succeeds → legacy path works. +4. If app create returns 409 with "HTTP voice feature is required" → the account doesn't have voice enabled. Contact Bandwidth support. + +## Idempotency + +**Use `--if-not-exists`** on create commands to make them safe for retries: + +```bash +band subaccount create --name "My Site" --if-not-exists +band location create --site --name "My Location" --if-not-exists +band app create --name "My App" --type voice --callback-url --if-not-exists +band vcp create --name "My VCP" --if-not-exists +``` + +For `number order`, there is no `--if-not-exists` — check `band number list --plain` first. + +All read operations (gets, lists, deletes) are safe to retry. + +## Async Operations + +Use `--wait` to block until completion: + +```bash +band number order +19195551234 --wait # blocks until number is active (30s default) +band call create --from ... --to ... --wait --timeout 120 # blocks until call completes +band transcription create --wait # blocks until transcription ready (60s default) +``` + +All `--wait` commands support `--timeout `. Exit code 5 on timeout. + +## Output + +**Always use `--plain` when parsing CLI output.** Default JSON reflects Bandwidth's API structure with deep nesting. `--plain` flattens it: + +```bash +band number list --plain # → ["+19193554167", "+19198234157", ...] +band subaccount list --plain # → [{"Id":"152681","Name":"Subacct"}] +band app list --plain # → [{"ApplicationId":"abc-123", ...}, ...] +band app get --plain # → {"ApplicationId":"abc-123", "AppName":"My App", ...} +``` + +List commands with `--plain` always return arrays, even for a single result. No type-checking needed. + +**Auto-plain when piped:** When stdout is piped to another command (e.g., `band number list | jq ...`), `--plain` is automatically enabled. Agents running in pipelines don't need to pass the flag explicitly. + +## Global Flags + +| Flag | Purpose | +|------|---------| +| `--plain` | **Recommended for agents.** Flat, simplified JSON output | +| `--format ` | Output format (default: json) | +| `--account-id ` | Override active account for this command | +| `--environment ` | API environment: prod, test | + +## Behavioral Notes + +For full flag/argument reference, use `band --help`. This section covers non-obvious semantics that affect agent control flow. + +### Messaging + +- **`message send` runs preflight checks** that block the send when provisioning is wrong. Handle exit code 1 from preflight failures — the error message contains the fix command. +- **`message send` returns 202, not 200.** A 202 means "accepted for processing," not "delivered." An agent must not report delivery success based on a 202. Delivery confirmation arrives via webhooks on the callback server. +- **`message media upload` outputs the media URL to stdout.** Chain it: `MEDIA_URL=$(band message media upload image.png)` then pass to `--media`. +- **`message list` requires at least one filter** (`--to`, `--from`, `--start-date`, or `--end-date`). Calling with no filters returns a 400 error. +- **`message list` date filters require millisecond precision:** `2024-01-01T00:00:00.000Z`, not `2024-01-01T00:00:00Z`. + +### Applications + +- **`app assign` is required for messaging** — it links a messaging app to a location. Without it, messages silently vanish (202 accepted, never delivered, no error). Voice on UP doesn't need this (VCPs handle it), but messaging always does. +- **`app create --type messaging`** sets `MsgCallbackUrl`, not `CallInitiatedCallbackUrl`. The callback URL receives delivery webhooks. +- **`app update` auto-detects** whether the app is voice or messaging and sets the appropriate callback field. + +### Numbers + +- **`number order` costs money.** No undo — you must `number release` to give it back. +- **`number search` results are not reserved.** Between search and order, someone else can take the number. + +### VCPs + +- **`vcp delete` fails if numbers are assigned.** Move them first with `vcp assign `. +- **`vcp assign` is an upsert.** Numbers already on another VCP are moved, not duplicated. + +### Quickstart + +- **Agents should not use `band quickstart`.** It creates real resources that cost money (orders a phone number), doesn't support `--if-not-exists` (running it twice creates duplicate resources and orders a second number), doesn't return structured output for each step, and can't be partially retried if it fails midway. Use the step-by-step provisioning workflows in the [Agent Workflows](#agent-workflows) section instead. + +--- + +## Timeout Recovery + +When `--wait` times out (exit code 5), the operation may have succeeded — the CLI just stopped waiting. + +| Command | On timeout | Recovery | +|---------|-----------|----------| +| `number order --wait` | Number may be activating | Check `band number list --plain` — if the number appears, it completed. If not, retry the order. | +| `call create --wait` | Call may still be active | Check `band call get --plain` — look at the `state` field. | +| `transcription create --wait` | Transcription may be processing | Check `band transcription get --plain`. | + +**General rule:** after a timeout, query the resource state before retrying. Don't blindly re-run a create that might have succeeded. + +--- + +## Agent Workflows + +### Build Registration: Create a new account from zero + +Use when no credentials exist yet. The CLI submits the registration request; the remaining setup happens in the browser. **An agent cannot complete this flow autonomously** — it requires a human (or an agent with web/phone access) to finish. + +```bash +band account register --phone +19195551234 --email you@example.com --first-name Jane --last-name Doe --accept-tos +# → registration submitted; remaining steps happen outside the CLI: +# 1. Check email for a registration link from Bandwidth +# 2. Enter the OTP code sent via SMS to verify the phone number +# 3. Set a password and enter the OTP code from the email +# 4. Go to Account > API Credentials to generate OAuth2 credentials +# → once credentials are available: +band auth login --client-id --client-secret +band auth status # confirm +``` + +**Important for agents:** Registration requires accepting the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/). Before passing `--accept-tos`, you **must** present the full Terms of Service URL to the user and get their explicit confirmation. Do not accept on the user's behalf without showing them the terms first. The flow should be: + +1. Show the user: "Registration requires accepting the Bandwidth Build Terms of Service: https://www.bandwidth.com/legal/build-terms-of-service/" +2. Ask the user to review and confirm they accept +3. Only after confirmation, run the command with `--accept-tos` + +After calling `band account register`, stop and tell the user they need to complete setup in their browser. Do not attempt to poll or wait — 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` and `band number list --plain` to discover them — do **not** call `app create` or `number order` on a fresh Build account, you already have what you need to make a call. + +--- + +### Prerequisite Chains + +Different operations have different prerequisites. Use this to determine what's needed: + +**Voice (Universal Platform):** +``` +account + auth + └─→ app create (voice) + └─→ vcp create (links to app) + └─→ number search → number order + └─→ vcp assign + └─→ call create +``` + +**Voice (Legacy):** +``` +account + auth + └─→ subaccount create + └─→ location create + └─→ app create (voice) + └─→ number search → number order + └─→ call create +``` + +**Messaging (all accounts):** +``` +account + auth + └─→ subaccount (check existing first) + └─→ location (check existing first) + └─→ app create (messaging, with real callback URL) + └─→ app assign (link app to location) + └─→ 10DLC campaign (local numbers) or TFV approval (toll-free) + └─→ message send +``` + +**Key difference:** Voice on UP skips the sub-account/location hierarchy. Messaging always needs it, even on UP accounts. + +--- + +### Diagnose: What state am I in? + +When inheriting a partially-provisioned account, run these commands to assess what's set up: + +```bash +band auth status --plain # logged in? which account? +band subaccount list --plain # any sub-accounts? +band location list --site --plain # any locations? +band app list --plain # any applications? +band number list --plain # any phone numbers? +band vcp list --plain # VCPs? (403 = legacy account, use sub-account path) +``` + +For messaging readiness, also check: +```bash +band tendlc campaigns --plain # any 10DLC campaigns? (403 = see note below) +band tendlc number --plain # is a specific number registered? +band tfv get --plain # toll-free verification status? +``` + +**If `band tendlc` returns a 403:** This could mean one of three things — your credential lacks the Campaign Management role, your account doesn't have the Registration Center feature enabled, or messaging isn't enabled on the account. Contact your Bandwidth account manager to check your account configuration and request Registration Center access if needed. + +--- + +### Universal Platform: Provision voice from scratch + +```bash +band auth status # 1. verify auth +band app create --name "Agent Voice" --type voice --callback-url --if-not-exists --plain # 2. create app +band vcp create --name "Agent VCP" --app-id --if-not-exists --plain # 3. create VCP linked to app +band number list --plain # 4. check existing numbers +# if no numbers: +band number search --area-code 919 --quantity 1 --plain +band number order --wait # 5. order number +band vcp assign # 6. assign number to VCP +``` + +If step 2 fails with 409 "HTTP voice feature is required," or step 3 fails with 403, fall back to legacy. + +### Legacy: Provision voice from scratch + +```bash +band auth status # 1. verify auth +band subaccount create --name "Agent Site" --if-not-exists --plain # 2. sub-account +band location create --site --name "Agent Location" --if-not-exists --plain # 3. location +band app create --name "Agent Voice" --type voice --callback-url --if-not-exists --plain # 4. app +band number list --plain # 5. check numbers +# if no numbers: +band number search --area-code 919 --quantity 1 --plain +band number order --wait # 6. order number +``` + +### Provision messaging from scratch + +**Messaging uses a different provisioning model than voice.** Voice on UP uses VCPs (no sub-account/location needed). Messaging always requires the sub-account → location → application chain — even on Universal Platform accounts. This is because phone numbers live inside locations (SIP peers), and messaging applications are linked to locations, not directly to numbers. Every number in a location inherits its messaging app. If you just completed the voice UP workflow, don't assume messaging follows the same pattern. + +A fresh UP account typically has one sub-account and one location already created. Check before creating new ones: + +```bash +band auth status # 1. verify auth +band subaccount list --plain # 2. check existing sites +band location list --site --plain # 3. check existing locations + +# If no sub-account or location exists (--if-not-exists returns the existing +# resource if one with the same name already exists — same output shape either way, +# so you can always parse the ID from the response): +band subaccount create --name "Agent Site" --if-not-exists --plain +band location create --site --name "Agent Location" --if-not-exists --plain + +# 4. Create a messaging application with a REAL callback URL +# The CLI blocks sends if this URL is a placeholder like example.com or localhost. +band app create --name "Agent SMS" --type messaging --callback-url --if-not-exists --plain + +# 5. Link the app to the location where your numbers live +band app assign --site --location + +# 6. Send (CLI checks campaign assignment automatically and blocks if missing) +band message send --from --to --app-id --text "Hello" +``` + +**Preflight failure recovery.** If step 6 fails, the error message contains the fix: + +| Error contains | Cause | Fix | +|---|---|---| +| `"not linked to any location"` | App not assigned to a location | `band app assign --site --location ` | +| `"no working callback URL"` | Callback URL is placeholder or missing | `band app update --callback-url ` | +| `"not assigned to any active 10DLC campaign"` | Number not on a campaign | `band tendlc campaigns --plain` to list campaigns; `band tnoption assign --campaign-id ` to assign | +| `"toll-free verification status"` | TFV not approved | `band tfv get --plain` to check status | + +### Send a message + +Once provisioning is set up, sending is straightforward: + +```bash +band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text "Hello from the agent" +# → preflight checks pass (app linked, callback URL valid, number on campaign) +# → returns JSON with message id, segmentCount, direction +``` + +**Message delivery is async and webhook-based.** The CLI cannot verify whether a message was actually delivered. A 202 means "accepted for processing." Delivery confirmations (`message-delivered`, `message-failed`) arrive via webhooks on the app's callback URL. **An agent should not report "message delivered" based on a 202 — only report "message sent."** True delivery status requires a callback server. + +**Sending MMS with uploaded media:** + +```bash +MEDIA_URL=$(band message media upload image.png) +band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text "Check this out" --media "$MEDIA_URL" +``` + +**Group messaging** uses the same `send` command with multiple recipients: + +```bash +band message send --from +19195551234 --to +15551234567,+15552345678 --app-id abc-123 --text "Team update" +``` + +**Listing messages** requires at least one filter and **millisecond-precision timestamps** (a common agent mistake): + +```bash +# Correct — milliseconds in the timestamp: +band message list --from +19195551234 --start-date 2024-01-01T00:00:00.000Z --plain +# Wrong — this returns a 400: +band message list --from +19195551234 --start-date 2024-01-01T00:00:00Z --plain +``` + +### Make a call + +```bash +band number list --plain # → ["+19195551234", ...] +band app list --plain # → [{"ApplicationId":"abc-123", ...}, ...] +band call create --from +19195551234 --to +15559876543 --app-id abc-123 --answer-url +# → returns JSON with callId + +# IMPORTANT: always verify the call actually connected +band call get --plain +# Check: state should be "active" or disconnectCause should be "hangup" +# If disconnectCause is "error" or errorMessage is "Service unavailable", +# the call never went out — try a different --from number or re-check provisioning. +``` + +**Calls can fail silently.** `call create` returns 200 with a callId even when the call fails immediately (e.g., number not properly provisioned, routing error). Always verify with `call get` before reporting success to the user. + +### Check call outcome + +```bash +band call get --plain # check state +band recording list --plain # recordings +band transcription create --wait --plain # blocks until ready +``` + +**Interpreting call state:** + +| `disconnectCause` | Meaning | +|---|---| +| `hangup` | Call connected and ended normally | +| `busy` | Callee was busy | +| `timeout` | No answer | +| `error` | Call never connected — check `errorMessage` for details | + +### Find number-to-app mapping + +**Look up a specific number's VCP:** +```bash +band number get +19195551234 --plain # → shows VCP assignment and voice settings +``` + +**List all numbers on a VCP:** +```bash +band vcp numbers --plain # → numbers assigned to this VCP +``` + +**Legacy:** +```bash +band app peers --plain # → locations linked to app (includes SiteId) +band number list --plain # → all numbers on account +``` + +## Exit Codes + +| Code | Meaning | When | +|------|---------|------| +| 0 | Success | Command completed | +| 1 | General error | Missing flags, invalid input, unexpected failures | +| 2 | Auth/permission error | 401/403 — bad credentials, token expired, or credential lacks a required role (e.g., VCP, Campaign Management, TFV). An agent's branching logic should treat exit code 2 as "try a different path or escalate" rather than only "re-authenticate" | +| 3 | Not found | 404 — resource doesn't exist | +| 4 | Conflict | 409 — duplicate resource or feature not enabled | +| 5 | Timeout | `--wait` exceeded `--timeout` | + +**Use exit codes for control flow, not string parsing.** + +## Error Patterns + +| Error | Exit Code | Cause | Fix | +|-------|-----------|-------|-----| +| "not logged in" | 1 | No stored credentials | `BW_CLIENT_ID=x BW_CLIENT_SECRET=y band auth login` | +| "account ID not set" | 1 | No active account | `band auth switch ` or pass `--account-id` | +| "credential verification failed" | 2 | Bad client ID or secret | Check credentials | +| "API error 401" | 2 | Token expired or invalid | Re-run `band auth login` | +| "API error 403" | 2 | Credential lacks permission | Check roles — VCP role for UP voice, Campaign Management role for `tendlc`, TFV role for `tfv`. Could also mean the account doesn't have the Registration Center feature enabled. Escalate to account manager if unclear | +| "API error 404" | 3 | Resource doesn't exist | Verify the ID; check you're on the right account | +| "API error 409" | 4 | Conflict / duplicate | Use `--if-not-exists`; or feature not enabled on account | +| "HTTP voice feature is required" | 4 | Legacy voice not available | Try VCP path (UP account) or contact support | +| "required flag not set" | 1 | Missing a required flag | Check `--help` for required flags | + +### Messaging delivery errors + +These are **not CLI errors** — the CLI returns 0 (send was accepted). Delivery failures arrive via webhooks on your messaging application. Key error codes: + +| Webhook error code | Meaning | Fix | +|---|---|---| +| **4476** | Source TN not registered to a 10DLC campaign | `band tnoption assign --campaign-id --wait` | +| **4770** | AT&T carrier block | Campaign reputation issue or content violation | +| **5620** | T-Mobile carrier block | Number not registered for 10DLC (T-Mobile blocks even inbound) | +| **5229** | TN-to-campaign provisioning error | Check sub-error: campaign suspended, TN on another campaign, or downstream partner error | + +**An agent should never assume a 202 means delivery succeeded.** If delivery confirmation matters, the agent's callback server must listen for `message-delivered` or `message-failed` webhook events. + +## 10DLC Registration (Registration Center) + +These commands query the Registration Center API for 10DLC campaign and phone number registration status. + +**Important:** These commands are for **import customers** — accounts that register campaigns through TCR and import them to Bandwidth. They require the **Campaign Management role** on your API credential and the **Registration Center feature** on your account. + +**Direct customers** (accounts that register campaigns directly through Bandwidth) are not yet supported by these commands. Direct registration through the CLI is planned for mid-2026. In the meantime, direct customers should use the Bandwidth App or the existing Campaign Management API. + +A 403 from `band tendlc` can mean: credential lacks the Campaign Management role, account doesn't have Registration Center, account is a direct customer, or messaging isn't enabled. The CLI parses the API response and gives a specific message for each case. + +### Check if a number is registered for 10DLC + +```bash +band tendlc number +19195551234 --plain +# → { "phoneNumber": "+19195551234", "campaignId": "CA3XKE1", "status": "SUCCESS", "brandId": "B1DER2J", ... } +``` + +Status values: `SUCCESS` (ready to send), `PROCESSING` (pending), `FAILURE` (registration failed). + +### List all 10DLC campaigns + +```bash +band tendlc campaigns --plain +# → [{ "campaignId": "CA3XKE1", "status": "SUCCESS", "brandId": "B1DER2J", ... }, ...] +``` + +### List all registered numbers (with filters) + +```bash +band tendlc numbers --plain # all registered numbers +band tendlc numbers --campaign-id CA3XKE1 --plain # numbers on a specific campaign +band tendlc numbers --status SUCCESS --plain # only successfully registered numbers +band tendlc numbers --status FAILURE --plain # numbers with registration failures +``` + +### List numbers on a specific campaign + +```bash +band tendlc campaigns numbers CA3XKE1 --plain +``` + +### Diagnose messaging send failures + +When `message send` fails with "not assigned to any active 10DLC campaign": + +```bash +# 1. Check the specific number's registration +band tendlc number +19195551234 --plain + +# 2. If not registered, list available campaigns +band tendlc campaigns --plain + +# 3. Assign the number to a campaign +band tnoption assign +19195551234 --campaign-id CA3XKE1 --wait +``` + +**If `band tendlc` returns 403:** Don't retry — escalate. Tell the user: "Your credential may not have the Campaign Management role, or your account may not have the Registration Center feature enabled. Contact your Bandwidth account manager to check your configuration." + +## Toll-Free Verification (TFV) + +These commands manage toll-free number verification via the Athena v2 API. A 403 means the TFV role isn't enabled on the credential — contact your Bandwidth account manager to enable it. + +### Check verification status + +```bash +band tfv get +18005551234 --plain +# → { "status": "VERIFIED", "phoneNumber": "+18005551234", "submission": { ... } } +``` + +Status values: `VERIFIED` (approved, ready to send), `PENDING` (under review), `REJECTED` (resubmit needed). + +### Submit a verification request + +```bash +band tfv submit +18005551234 \ + --business-name "Acme Corp" \ + --business-addr "123 Main St" \ + --business-city "Raleigh" \ + --business-state "NC" \ + --business-zip "27606" \ + --contact-first "Jane" \ + --contact-last "Doe" \ + --contact-email "jane@acme.com" \ + --contact-phone "+19195551234" \ + --message-volume 10000 \ + --use-case "2FA" \ + --use-case-summary "Two-factor auth codes for user login" \ + --sample-message "Your Acme code is 123456" \ + --privacy-url "https://acme.com/privacy" \ + --terms-url "https://acme.com/terms" \ + --entity-type "PRIVATE_PROFIT" +``` + +### Diagnose toll-free messaging failures + +When `message send` fails with toll-free verification issues: + +```bash +# Check the number's TFV status +band tfv get +18005551234 --plain +# If PENDING → wait for carrier review +# If REJECTED → resubmit with corrected information +# If 404 → no verification request exists, submit one +``` + +## Short Codes + +These commands query the Athena v2 API for short code registration and carrier activation status. Short codes are provisioned through carrier agreements outside the API — these commands are read-only. + +### List short codes on the account + +```bash +band shortcode list --plain +# → [{ "shortCode": "12345", "status": "ACTIVE", "country": "USA", "carrierStatuses": [...], ... }] +``` + +### Get details for a specific short code + +```bash +band shortcode get 12345 --plain +band shortcode get 12345 --country CAN --plain # Canadian short code +``` + +The response includes per-carrier activation status (`carrierStatuses` array), lease info, and which site/location the short code is assigned to. Status values: `ACTIVE`, `EXPIRED`, `SUSPENDED`, `INACTIVE`. + +## TN Option Orders + +TN Option Orders assign phone numbers to 10DLC campaigns (and can set other per-number options). This is the missing step between "number exists" and "number can send messages." + +### Assign a number to a campaign + +```bash +band tnoption assign +19195551234 --campaign-id CA3XKE1 --wait --plain +# → order completes when status is COMPLETE +``` + +Multiple numbers in one order: + +```bash +band tnoption assign +19195551234 +19195551235 --campaign-id CA3XKE1 --wait +``` + +### Check order status + +```bash +band tnoption get --plain +# → { "ProcessingStatus": "COMPLETE", ... } +``` + +### List recent orders + +```bash +band tnoption list --plain +band tnoption list --status FAILED --plain +band tnoption list --tn +19195551234 --plain +``` + +### Common errors + +| Error code | Message | Cause | Fix | +|---|---|---|---| +| **1022** | "TelephoneNumber is in an invalid format" | Number not in E.164 format | Pass numbers with `+` prefix: `+19195551234` | +| **12220** | "Campaign has been rejected by DCA2" | Campaign failed carrier compliance review | Fix campaign compliance in the Bandwidth App, then retry | +| **5132** | "SMS attribute should be 'ON' for provisioning A2P" | SMS not enabled on the number's SIP peer/location | Enable SMS on the location in the Bandwidth App | +| **5133** | "A2P provisioning requires A2P on corresponding Sip peer" | Location not configured for A2P messaging | Enable A2P on the location in the Bandwidth App | + +### Full messaging send-readiness workflow + +```bash +# 1. Check if number is on a campaign +band tendlc number +19195551234 --plain + +# 2. If not, find an available campaign +band tendlc campaigns --plain + +# 3. Assign the number (use full E.164 format with + prefix) +band tnoption assign +19195551234 --campaign-id CA3XKE1 --wait + +# 4. If assign fails with 5132/5133, SMS or A2P isn't enabled on the +# number's location — this must be fixed in the Bandwidth App before retrying + +# 5. Verify assignment +band tendlc number +19195551234 --plain +# → status should be SUCCESS + +# 6. Now send +band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text "Hello" +``` + +## Limitations + +- **No real-time call control.** The CLI can initiate calls and query state, but cannot receive or respond to mid-call callbacks. Dynamic call control requires a separate callback-handling server. +- **No message delivery confirmation.** The CLI verifies your setup is correct before sending (app-location link, callback URL, campaign), but it cannot confirm whether a message was actually delivered. Delivery status (`message-delivered`, `message-failed`) arrives via webhooks on your callback server. The CLI's `message get` and `message list` return metadata only — not delivery status. +- **No message content retrieval.** Bandwidth does not store message bodies. After sending, the message text is gone forever. `message get` and `message list` return timestamps, direction, and segment counts only. +- **10DLC: read + assign only.** The CLI can list campaigns, check number registration status, diagnose failures (`band tendlc`), and assign numbers to campaigns (`band tnoption assign`). It cannot create campaigns or register brands — those require the Bandwidth App. The CLI checks that a number is on a campaign and blocks sends if it's not. +- **TFV is check-and-submit.** The CLI can check toll-free verification status and submit new requests (`band tfv`), but cannot approve or expedite reviews — those happen on the carrier side. +- **10DLC, TFV, and short code commands are role-gated.** A 403 can mean the credential lacks the required role (Campaign Management, TFV), the account doesn't have the Registration Center feature, or messaging isn't enabled. The CLI provides a diagnostic message — if it says "access denied," escalate to the Bandwidth account manager rather than retrying. +- **No batch operations.** Each command operates on one resource (except `vcp assign` which handles multiple numbers and `message send` which supports multiple recipients). +- **Dashboard API uses XML internally.** The CLI handles XML serialization transparently — you always send and receive JSON. Use `--plain` for predictable, flat output. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e29750b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to the Bandwidth CLI are documented in [GitHub Releases](https://github.com/Bandwidth/cli/releases). + +For upgrade instructions between versions, check the release notes for each version. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..109f454 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[swi@bandwidth.com](mailto:swi@bandwidth.com). + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1f84668 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to the Bandwidth CLI + +Thanks for wanting to contribute. Here's how to get going and what to expect. + +## Quick start + +```sh +git clone https://github.com/Bandwidth/cli.git +cd cli +make build # compile → ./band +make test # run tests +make lint # run golangci-lint +``` + +Requires Go 1.22+ and [golangci-lint](https://golangci-lint.run/welcome/install/). + +## What happens when you open a PR + +CI runs automatically on every pull request. Your PR needs to pass all of these before it can merge: + +| Check | What it does | +|-------|-------------| +| **Test** | `go test ./...` on Ubuntu, macOS, and Windows | +| **Lint** | `golangci-lint` — catches style issues, dead code, common bugs | +| **Security** | `govulncheck` — flags known vulnerabilities in dependencies | +| **Docs check** | Warns if you changed commands/flags without updating README.md or AGENTS.md | + +If lint or tests fail, fix them locally before pushing again. Don't open a "fix CI" follow-up PR. + +## What we accept + +- **Bug fixes** — always welcome. Include a test that would have caught the bug. +- **New flags or options on existing commands** — usually straightforward. Open an issue first if you're unsure. +- **New commands for existing Bandwidth APIs** — open an issue to discuss before writing code. We want to make sure the command design fits the CLI's patterns. +- **Documentation improvements** — typo fixes, clarifications, better examples. Ship it. +- **Test coverage** — more tests are always good. + +## What we don't accept + +- **New commands without prior discussion.** Don't spend hours on a PR we haven't agreed on. Open an issue first. +- **Large refactors or architectural changes** without a design discussion. File an issue, explain the problem, propose a solution. +- **Dependencies for things the standard library handles.** We keep the dependency tree small. +- **Cosmetic-only changes** (reformatting, renaming, reordering imports) that don't fix a bug or add a feature. These create noise in git blame and review queues. + +## Agent compatibility + +This CLI is agent-native. If your change affects command output, flags, or exit codes, see the design principles at the top of [AGENTS.md](AGENTS.md) and update that file alongside README.md. + +## AI-generated contributions + +We accept AI-assisted contributions, but they must be reviewed and understood by a human before submission. If your PR was substantially generated by an AI tool, note that in the PR description. We'll hold AI-generated PRs to the same quality bar as any other — tests, lint, docs, and a clear explanation of what the change does and why. + +Bulk AI-generated PRs (typo-fixing sweeps, mass refactors, etc.) tend to introduce subtle issues and create review burden disproportionate to their value. Discuss first in an issue. + +## Code style + +- Run `make lint` before pushing. If it passes, you're good. +- `gofmt` is non-negotiable. The linter enforces it. +- Keep functions short. If a function needs a comment explaining what it does, it might need to be split. +- Error messages should be lowercase and start with the operation that failed: `"creating application: %w"`, not `"Failed to create application"`. +- Don't add comments that restate the code. Add comments that explain *why* something non-obvious is happening. + +## Project structure + +``` +cmd/ + band/ Entrypoint (main.go) + / One package per command group (auth, app, call, etc.) +internal/ + api/ HTTP client — JSON and XML modes, Requester interface + auth/ OAuth2 token manager, OS keychain storage + config/ Config file management (~/.config/band/) + cmdutil/ Shared command helpers (output flags, client creation) + output/ JSON formatting and response flattening + ui/ Terminal UI helpers (colors, spinners, progress output) +``` + +Commands live in `cmd//`. Each command group gets its own package. Tests live next to the code they test (e.g., `cmd/tendlc/tendlc_test.go`, `internal/api/client_test.go`). The `internal/` packages are shared infrastructure — touch these carefully. + +## Commit messages + +Write clear commit messages. We're not prescriptive about format, but: + +- First line: short summary of what changed (under 72 chars) +- Body (if needed): explain *why*, not *what* — the diff shows what changed + +## Questions? + +Open an issue. We're happy to help you figure out whether your idea fits before you invest time in code. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ae92b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o band ./cmd/band + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +COPY --from=builder /app/band /usr/local/bin/band +ENTRYPOINT ["band"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..527c726 --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,4 @@ +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +COPY band /usr/local/bin/band +ENTRYPOINT ["band"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a5da923 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2020-2026 Bandwidth, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a491f89 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: build test install clean lint snapshot + +build: + go build -o band ./cmd/band + +test: + go test ./... -v + +install: + go build -o "$$(go env GOPATH)/bin/band" ./cmd/band + +clean: + rm -f band + +lint: + golangci-lint run ./... + +snapshot: + goreleaser release --snapshot --clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..64e6726 --- /dev/null +++ b/README.md @@ -0,0 +1,590 @@ +# band — The Bandwidth CLI + +Manage phone numbers, voice calls, and messaging from your terminal. No dashboard clicking, no API wrangling — just straightforward commands that get things done. + +```sh +band call create --from +19195551234 --to +15559876543 --app-id abc-123 --answer-url https://example.com/answer +``` + +Built for humans, but agent-native from day one — every command supports `--plain` for flat JSON, `--if-not-exists` for safe retries, and `--wait` for async operations. If you're building an AI agent that provisions phone numbers or makes calls, this is the interface. + +--- + +## Install + +### Homebrew (macOS and Linux) + +```sh +brew install Bandwidth/tap/band +``` + +### Go install + +```sh +go install github.com/Bandwidth/cli/cmd/band@latest +``` + +### Download a binary + +Pre-built binaries for macOS, Linux, and Windows are on the [GitHub Releases](https://github.com/Bandwidth/cli/releases) page. + +### Docker + +```sh +docker run --rm ghcr.io/bandwidth/cli:latest version +``` + +## Log in + +The CLI uses OAuth2 client credentials — a **client ID** and **client secret** from the Bandwidth App. + +```sh +band auth login --client-id --client-secret +``` + +That's it. The CLI validates your credentials, figures out which accounts you can access, and stores everything in your OS keychain. If your credentials work with multiple accounts, you'll pick one. + +### Switch accounts + +```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 +``` + +You can also pass `--account-id` to any command to override the active account for a single call. + +### Credential profiles + +Need to juggle multiple sets of credentials — say, one for each environment or role? Named profiles keep them organized: + +```sh +band auth login --profile admin # store credentials under "admin" +band auth profiles # list all profiles +band auth use admin # switch to the "admin" profile +``` + +When more than one account or profile is active, commands print an `[account: X | profile: Y]` hint to stderr so you always know which account you're targeting. It's stderr only, so it won't break scripts that parse stdout. + +### Headless and CI/CD + +Set environment variables instead of flags: + +```sh +export BW_CLIENT_ID= +export BW_CLIENT_SECRET= +band auth login +``` + +No TTY required. Accounts are auto-discovered from the OAuth2 token. + +### Don't have a Bandwidth account yet? + +You can sign up for a Bandwidth Build trial account from the CLI: + +```sh +band account register --phone +19195551234 --email you@example.com --first-name Jane --last-name Doe +``` + +You'll be prompted to accept the [Bandwidth Build Terms of Service](https://www.bandwidth.com/legal/build-terms-of-service/) before registration proceeds. For scripted usage, pass `--accept-tos`. + +Then complete setup in your browser: + +1. Check your email for a registration link from Bandwidth +2. Enter the OTP code sent via SMS to verify your phone number +3. Set your password and enter the OTP code from your email +4. Go to **Account > API Credentials** to generate your OAuth2 credentials + +Once your credentials are ready, run `band auth login` and you're off. + +**What you get:** Every Build account ships with a voice application and a phone number already provisioned — no need to create them yourself. After login, run `band app list` and `band number list` to see them, and skip straight to [make a call](#make-a-call). + +**Important note**: a Bandwidth Build account is for our Voice API **only**. Usage limits and terms and conditions apply. If you would like to send +messages, order numbers, and more, you will need a full Bandwidth Account. [Talk to an expert](https://www.bandwidth.com/talk-to-an-expert/) to start +your onboarding process today. + +--- + +## What do I need? + +Different tasks have different prerequisites. Here's what's required before you can do the main things: + +| I want to... | I need... | +|---|---| +| **Make a call** | Bandwidth Build or full Bandwidth account + auth + voice application + phone number + VCP (or legacy site/location) + callback server | +| **Send a message** | Full Bandwidth account + auth + messaging application + phone number + app-location link + callback server + 10DLC campaign (local) or TFV approval (toll-free) | +| **Order a number** | Full Bandwidth account + auth | +| **Generate BXML** | Nothing — works offline, no auth needed | + +If you don't have an account yet, start with `band account register` [above](#dont-have-a-bandwidth-account-yet) to start a free trial. Each walkthrough below builds up from auth. + +--- + +## Your first call in 5 minutes + +Already have phone numbers and an application set up? Skip to [make a call](#make-a-call). + +Starting from scratch? Here's the full setup — you'll create a voice application, get a phone number, and place a call. This uses the **Universal Platform (VCP) path**, which is the default for new accounts. If your account uses the older sub-account model, see [legacy setup](#legacy-setup). + +### 1. Create a voice application + +An application tells Bandwidth where to send webhooks when something happens on a call. Point it at your server's callback URL. + +```sh +band app create --name "My Voice App" --type voice --callback-url https://your-server.example.com/callbacks +``` + +### 2. Get a phone number + +Search for available numbers, then order one: + +```sh +band number search --area-code 919 --quantity 1 +band number order +19195551234 --wait +``` + +The `--wait` flag blocks until the number is active, so you don't have to poll. + +### 3. Connect the number to your application + +Phone numbers don't do anything on their own — you need to tell Bandwidth how to handle calls to them. That's what a Voice Configuration Package (VCP) does. A VCP is a bundle of voice settings (routing rules, caller ID lookup, call verification) that you apply to a group of numbers. The most important setting is which application receives the webhooks. + +```sh +band vcp create --name "My VCP" --app-id +band vcp assign +19195551234 +``` + +Now when someone calls that number, Bandwidth routes the call to your application's callback URL. + +If `vcp create` fails with a 403, your account uses the older sub-account model instead. See [legacy setup](#legacy-setup) below. + +### 4. Make a call + +> **Important:** The CLI starts calls and checks their status, but it can't control what happens *during* a call. That's your callback server's job. When Bandwidth reaches your `--answer-url`, your server responds with BXML (Bandwidth XML) — instructions like "say this," "gather digits," or "transfer to this number." If you don't have a callback server running, the call will connect but immediately hang up. + +> **`--callback-url` vs `--answer-url`:** The `--callback-url` you set on the application (step 1) receives event webhooks — status changes, recordings, etc. The `--answer-url` you pass to `call create` is where Bandwidth fetches BXML instructions when the call connects. They can be the same URL or different endpoints on the same server. + +```sh +band call create \ + --from +19195551234 \ + --to +15559876543 \ + --app-id \ + --answer-url https://your-server.example.com/answer +``` + +Need a quick callback server to test with? Here's a minimal one in Node.js: + +```js +// server.js — run with: node server.js +const http = require("http"); +http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/xml" }); + res.end(` + + Hello from Bandwidth. Your call is working. +`); +}).listen(3000, () => console.log("BXML server on port 3000")); +``` + +Expose it with a tool like ngrok (`ngrok http 3000`), then use that public URL as your `--answer-url`. + +### Generate BXML + +BXML (Bandwidth XML) is how you tell a call what to do — speak text, gather key presses, transfer to another number, or start recording. Your callback server responds with BXML, and Bandwidth executes it. + +The CLI can generate BXML for you locally. No API calls, no auth required — it just prints XML to stdout. + +```sh +band bxml speak "Thanks for calling. How can we help?" +band bxml speak --voice julie "Press 1 for sales." +band bxml gather --url https://example.com/gather --max-digits 1 --prompt "Press a key" +band bxml transfer +19195551234 --caller-id +19195550000 +band bxml record --url https://example.com/done --max-duration 60 +band bxml raw 'Hello' # validate and pretty-print XML +``` + +Pipe the output to a file, use it in tests, or serve it directly from your callback server: + +```sh +band bxml speak "Hello, thanks for calling." > greeting.xml +``` + +--- + +## Your first message + +Three steps: create a messaging app, link it to a location, send. + +### 1. Create a messaging application + +A messaging application tells Bandwidth where to send delivery status webhooks. Point it at your callback server. + +```sh +band app create --name "My SMS App" --type messaging --callback-url https://your-server.example.com/callbacks +``` + +### 2. Link the app to a location + +Phone numbers live inside locations, and messaging apps are linked to locations — not directly to numbers. Every number in a location inherits its messaging app. + +```sh +band subaccount list # find your subaccount ID +band location list --site # find your location ID +band app assign --site --location +``` + +### 3. Send a message + +```sh +band message send --to +15551234567 --from +15559876543 --app-id --text "Hello!" +``` + +The CLI runs preflight checks before sending and blocks you if something is wrong — you'll get a clear error message telling you exactly what to fix. If everything passes, you get a 202 response with the message ID. + +> **A 202 means "accepted for processing," not "delivered."** Delivery status arrives via webhooks on your callback server. See [how messaging delivery works](#how-messaging-delivery-works) for details. + +--- + +## Messaging provisioning details + +The messaging quickstart above covers the happy path. This section covers what else can go wrong and how to diagnose it. + +### Preflight checks + +The CLI checks three things before every send: + +| Check | What it verifies | What happens if it fails | +|-------|-----------------|-------------------------| +| **App-location link** | Messaging app is assigned to a location | Send blocked — tells you to run `band app assign` | +| **Callback URL** | App has a real callback URL (not `example.com`, `localhost`, etc.) | Send blocked — tells you to run `band app update --callback-url` | +| **Number registration** | 10DLC numbers are on an approved campaign; toll-free numbers have TFV approval | Send blocked — tells you what's missing | + +### 10DLC campaigns (local numbers) + +If you're sending from a standard 10-digit local number, it must be assigned to an approved 10DLC campaign. Without this, carriers will block your messages. The CLI detects this and blocks the send with a diagnostic message. + +You can check registration status with `band tendlc`: + +```sh +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 App — see [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`. + +### Toll-free verification (toll-free numbers) + +Toll-free numbers must be verified before messages will deliver. Check status or submit a verification request: + +```sh +band tfv get +18005551234 --plain # check verification status +band tfv submit +18005551234 ... # submit a new request (see band tfv submit --help) +``` + +### Why messaging needs sub-accounts and locations + +Voice on the Universal Platform uses VCPs — no sub-account/location hierarchy needed. Messaging is different: it always goes through the sub-account → location → application chain, even on UP accounts. This is because phone numbers live inside locations (SIP peers), and messaging apps are linked to locations, not directly to numbers. + +A fresh UP account typically has one sub-account and one location already created. Check before creating new ones. + +--- + +## Common tasks + +### Numbers + +```sh +band number list # list your numbers +band number search --area-code 919 --quantity 5 # search available numbers +band number order +19195551234 --wait # order (blocks until active) +band number release +19195551234 # release a number +``` + +### Messaging + +```sh +# SMS +band message send --to +15551234567 --from +15559876543 --app-id abc-123 --text "Hello" + +# MMS with media +band message send --to +15551234567 --from +15559876543 --app-id abc-123 --text "Check this out" --media https://example.com/image.png + +# Group message +band message send --to +15551234567,+15552345678 --from +15559876543 --app-id abc-123 --text "Hey everyone" + +# Pipe from stdin +echo "Alert: server is back up" | band message send --to +15551234567 --from +15559876543 --app-id abc-123 --stdin + +# List messages and media +band message list --from +15559876543 --start-date 2024-01-01T00:00:00.000Z # milliseconds required +band message media upload image.png # prints media URL to stdout +``` + +### Calls + +```sh +band call create --from +19195551234 --to +15559876543 --app-id abc-123 --answer-url https://example.com/answer +band call get # check state +band call hangup # hang up +band call update --redirect-url # redirect active call +``` + +### Recordings and transcriptions + +```sh +band recording list +band recording download --output call.wav +band transcription create --wait +``` + +--- + +## Legacy setup + +Some Bandwidth accounts use the older sub-account and location model instead of VCPs. If `band vcp list` returns a 403, you're on this path. + +```sh +band subaccount create --name "My Subaccount" +band location create --subaccount --name "My Location" +band app create --name "My Voice App" --type voice --callback-url https://your-server.example.com/callbacks +band number search --area-code 919 --quantity 1 +band number order +19195551234 --wait +``` + +Sub-accounts (formerly known as sites) are the top-level container. Locations (formerly known as SIP peers) sit inside sub-accounts and define where numbers get routed. The flow is: sub-account → location → application → number. + +> **Want one-command legacy setup?** Run `band quickstart --callback-url --legacy`. The default `band quickstart` uses the VCP path. + +--- + +## Command reference + +### Auth + +| Command | What it does | +|---------|-------------| +| `band auth login` | Log in with OAuth2 credentials (use `--profile ` to store under a named profile) | +| `band auth logout` | Clear stored credentials | +| `band auth status` | Show auth state, active account, and accessible accounts | +| `band auth switch [id]` | Switch to a different account | +| `band auth profiles` | List all stored credential profiles | +| `band auth use ` | Switch the active credential profile | + +### Account registration + +| Command | What it does | +|---------|-------------| +| `band account register` | Register a new Bandwidth account | + +### Applications + +| Command | What it does | +|---------|-------------| +| `band app create` | Create a voice or messaging application | +| `band app update ` | Update an application (e.g. change callback URL) | +| `band app assign ` | Link a messaging app to a location (`--site`, `--location`) | +| `band app list` | List all applications | +| `band app get ` | Get application details | +| `band app delete ` | Delete an application | +| `band app peers ` | Show locations linked to an app | + +### Voice configuration packages + +| Command | What it does | +|---------|-------------| +| `band vcp create` | Create a VCP | +| `band vcp list` | List all VCPs | +| `band vcp get ` | Get VCP details | +| `band vcp update ` | Update a VCP (name, description, linked app) | +| `band vcp delete ` | Delete a VCP | +| `band vcp assign ` | Assign (or move) numbers to a VCP | +| `band vcp numbers ` | List numbers on a VCP | + +### Numbers + +| Command | What it does | +|---------|-------------| +| `band number search` | Search available numbers by area code | +| `band number order ` | Order numbers | +| `band number get ` | Get voice config details (including VCP assignment) | +| `band number list` | List your in-service numbers | +| `band number release ` | Release a number | + +### Messaging + +| Command | What it does | +|---------|-------------| +| `band message send` | Send an SMS or MMS (supports group messaging and stdin) | +| `band message get ` | Get message metadata by ID | +| `band message list` | List messages (filter by `--to`, `--from`, `--start-date`, `--end-date`) | +| `band message media list` | List uploaded media files | +| `band message media upload ` | Upload a media file for MMS | +| `band message media get ` | Download a media file | +| `band message media delete ` | Delete a media file | + +### Calls + +| Command | What it does | +|---------|-------------| +| `band call create` | Start an outbound call | +| `band call list` | List calls | +| `band call get ` | Get call state | +| `band call update ` | Redirect an active call | +| `band call hangup ` | Hang up a call | + +### Recordings and transcriptions + +| Command | What it does | +|---------|-------------| +| `band recording list ` | List recordings for a call | +| `band recording get ` | Get recording metadata | +| `band recording download ` | Download the audio file | +| `band recording delete ` | Delete a recording | +| `band transcription create ` | Request a transcription | +| `band transcription get ` | Get the transcription | + +### Sub-accounts and locations (legacy) + +| Command | What it does | +|---------|-------------| +| `band subaccount create` | Create a sub-account (alias: `band site`) | +| `band subaccount list` | List sub-accounts | +| `band subaccount get ` | Get sub-account details | +| `band subaccount delete ` | Delete a sub-account | +| `band location create` | Create a location under a sub-account | +| `band location list` | List locations for a sub-account | + +### TN Option Orders + +| Command | What it does | +|---------|-------------| +| `band tnoption assign ` | Assign phone numbers to a 10DLC campaign | +| `band tnoption get ` | Check the status of a TN Option Order | +| `band tnoption list` | List TN Option Orders (filter by `--status`, `--tn`) | + +### Other + +| Command | What it does | +|---------|-------------| +| `band quickstart` | One-command setup: creates app, orders number, wires everything up (use `--legacy` for sub-account path) | +| `band bxml ` | Generate BXML locally (no auth needed) | +| `band version` | Print CLI version | + +--- + +## Useful flags + +| Flag | What it does | +|------|-------------| +| `--wait` | Block until an async operation finishes (ordering numbers, calls, transcriptions) | +| `--timeout ` | Set how long `--wait` should wait before giving up | +| `--if-not-exists` | Skip creating a resource if one with the same name already exists | +| `--plain` | Simplified, flat JSON output — great for scripting and piping | +| `--format json\|table` | Choose output format (default: json) | +| `--account-id ` | Override the active account for this command | +| `--environment ` | Target a different API environment (prod, test) | + +--- + +## Environment variables + +| Variable | What it does | +|----------|-------------| +| `BW_CLIENT_ID` | OAuth2 client ID | +| `BW_CLIENT_SECRET` | OAuth2 client secret | +| `BW_ACCOUNT_ID` | Override the active account | +| `BW_ENVIRONMENT` | API environment (prod, test) | +| `BW_FORMAT` | Default output format | +| `BW_API_URL` | Override the API base URL | +| `BW_VOICE_URL` | Override the Voice API base URL | + +--- + +## Troubleshooting + +**First step:** Run `band version` to check which version you're on. Include this when filing issues. + +**"not logged in"** — Run `band auth login` with your credentials. + +**"account ID not set"** — You're logged in but haven't picked an account. Run `band auth switch ` or pass `--account-id`. + +**"credential verification failed"** — Your client ID or secret is wrong. Double-check them in the Bandwidth App. + +**API error 401** — Your token expired. Run `band auth login` again. + +**API error 403** — Your credentials don't have permission for this operation. Check your roles in the Bandwidth App. VCP commands need the VCP role specifically. + +**API error 404** — The resource doesn't exist. Verify the ID and make sure you're on the right account. + +**API error 409** — You're trying to create something that already exists, or a feature isn't enabled on your account. Use `--if-not-exists` on create commands to handle duplicates gracefully. + +**"HTTP voice feature is required"** — Your account doesn't have voice enabled. Try the VCP path instead, or contact Bandwidth support. + +### Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Bad input or unexpected error | +| 2 | Authentication or permission problem | +| 3 | Resource not found | +| 4 | Conflict (duplicate resource or missing feature) | +| 5 | Timed out waiting | + +--- + +## How messaging delivery works + +The CLI does everything it can to prevent misconfigured sends — but it **cannot confirm delivery**. Here's why and what to do about it. + +When you run `band message send`, the CLI verifies your setup (app link, callback URL, campaign) and then submits the message to Bandwidth. Bandwidth returns 202 ("accepted for processing") and the CLI prints the message ID. That's where the CLI's visibility ends. + +**What happens next is entirely between Bandwidth and the carrier.** Your callback server receives one of: +- `message-delivered` — carrier accepted the message +- `message-failed` — delivery failed (includes an error code like 4476, 4770, etc.) + +If your callback server isn't running or can't be reached, these events are **lost forever**. There's no way to retroactively look them up. This is why the CLI blocks sends when there's no callback URL. + +**For production use:** make sure your callback server logs every delivery event. The `message.id` in the webhook matches the `id` returned by `band message send`. + +--- + +## How it works + +- **Auto-plain when piped.** If you pipe `band` output to another command (`band number list | jq ...`), `--plain` is automatically enabled. No flag needed. +- **Color and spinners.** The CLI uses color and spinners for interactive UX. All color output goes to stderr, so it never pollutes piped stdout. Set `NO_COLOR=1` to disable. +- **Config file** lives at `~/.config/band/config.json` (XDG standard). Falls back to `~/.band/config.json` if that doesn't exist. + +--- + +## For AI agents + +This CLI is agent-native — not just "agent-compatible." The design principles: + +- **`--plain` everywhere.** Flat, stable JSON output. Auto-enabled when stdout is piped, so agents in pipelines don't need the flag. +- **`--if-not-exists` for idempotency.** Create commands can be retried safely without duplicating resources. +- **`--wait` for async operations.** Agents can't poll. `--wait` blocks until the number is active, the call completes, or the transcription is ready. +- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict, 5 timeout. Use exit codes for control flow, not string parsing. +- **Env-var-driven auth.** `BW_CLIENT_ID` + `BW_CLIENT_SECRET` — no interactive prompts required. + +For the full agent reference — dependency chains, provisioning workflows, error patterns, and copy-pasteable scripts — see [AGENTS.md](AGENTS.md). + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, CI details, and guidelines. + +Quick start: + +```sh +git clone https://github.com/Bandwidth/cli.git +cd cli +make build # compile → ./band +make test # run tests +make lint # run golangci-lint +``` + +--- + +For full API docs, visit [dev.bandwidth.com](https://dev.bandwidth.com). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..86718c9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Reporting a vulnerability + +If you discover a security vulnerability in the Bandwidth CLI, **do not open a public issue.** Instead: + +1. **Preferred:** Report through our [Bug Bounty Program](https://www.bandwidth.com/security/report-a-vulnerability/), which is managed through Bugcrowd for initial triage. +2. **Alternatively:** Email [security@bandwidth.com](mailto:security@bandwidth.com) with details. + +We'll acknowledge your report within 5 business days and aim to provide a fix or mitigation plan within 30 days, depending on severity. + +## What counts as a vulnerability + +- Authentication bypass or credential leakage in the CLI itself +- Command injection or code execution through CLI inputs +- Insecure storage of credentials or tokens +- Privilege escalation through the CLI + +## What doesn't count + +**A dependency having a CVE does not automatically mean the CLI is vulnerable.** We use `govulncheck` in CI, which checks whether vulnerable code paths are actually reachable from our code — not just whether a dependency version appears in a database. + +If you're reporting a dependency CVE, please include: + +- The specific call chain from `band` code into the vulnerable function, or +- A proof of concept showing the vulnerability is exploitable through the CLI + +Reports that only list a dependency version and a CVE number without demonstrating reachability will need additional context before we can act on them. + +## Supported versions + +We support the latest released version. Security fixes are not backported to older releases. Upgrade to the latest version to get fixes. diff --git a/callback_server.py b/callback_server.py new file mode 100644 index 0000000..22f4e5a --- /dev/null +++ b/callback_server.py @@ -0,0 +1,25 @@ +from http.server import HTTPServer, BaseHTTPRequestHandler + +BXML = """ + + Hello! This is a test call from Bandwidth. Have a great day. Goodbye! + +""" + +class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/xml") + self.end_headers() + self.wfile.write(BXML.encode()) + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + self.rfile.read(content_length) + self.send_response(200) + self.send_header("Content-Type", "application/xml") + self.end_headers() + self.wfile.write(BXML.encode()) + +print("Callback server running on http://localhost:80") +HTTPServer(("0.0.0.0", 80), CallbackHandler).serve_forever() diff --git a/cmd/account/account.go b/cmd/account/account.go new file mode 100644 index 0000000..22e4f97 --- /dev/null +++ b/cmd/account/account.go @@ -0,0 +1,9 @@ +package account + +import "github.com/spf13/cobra" + +// Cmd is the `band account` parent command. +var Cmd = &cobra.Command{ + Use: "account", + Short: "Manage Bandwidth account registration", +} diff --git a/cmd/account/account_test.go b/cmd/account/account_test.go new file mode 100644 index 0000000..3b53e33 --- /dev/null +++ b/cmd/account/account_test.go @@ -0,0 +1,33 @@ +package account + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "account" { + t.Errorf("Use = %q, want %q", Cmd.Use, "account") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + if !subs["register"] { + t.Errorf("missing subcommand %q", "register") + } +} + +func TestRegisterRequiredFlags(t *testing.T) { + for _, flag := range []string{"phone", "email", "first-name", "last-name"} { + f := registerCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + continue + } + ann := registerCmd.Flags().Lookup(flag).Annotations + if _, ok := ann["cobra_annotation_bash_completion_one_required_flag"]; !ok { + t.Errorf("flag %q should be required", flag) + } + } +} diff --git a/cmd/account/register.go b/cmd/account/register.go new file mode 100644 index 0000000..54b677e --- /dev/null +++ b/cmd/account/register.go @@ -0,0 +1,116 @@ +package account + +import ( + "bufio" + "fmt" + "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 ( + registerPhone string + registerEmail string + registerFirstName string + registerLastName string + registerAcceptTOS bool +) + +const tosURL = "https://www.bandwidth.com/legal/build-terms-of-service/" + +func init() { + registerCmd.Flags().StringVar(®isterPhone, "phone", "", "Phone number (required)") + registerCmd.Flags().StringVar(®isterEmail, "email", "", "Email address (required)") + registerCmd.Flags().StringVar(®isterFirstName, "first-name", "", "First name (required)") + registerCmd.Flags().StringVar(®isterLastName, "last-name", "", "Last name (required)") + registerCmd.Flags().BoolVar(®isterAcceptTOS, "accept-tos", false, "Accept the Build Terms of Service (required; use for non-interactive mode)") + _ = registerCmd.MarkFlagRequired("phone") + _ = registerCmd.MarkFlagRequired("email") + _ = registerCmd.MarkFlagRequired("first-name") + _ = registerCmd.MarkFlagRequired("last-name") + Cmd.AddCommand(registerCmd) +} + +var registerCmd = &cobra.Command{ + Use: "register", + Short: "Create a new Bandwidth Build account", + Long: `Creates a new Bandwidth Build account. + +After registration, complete account setup in your browser: + 1. Check your email for a registration link from Bandwidth + 2. Enter the OTP code sent via SMS to verify your phone number + 3. Set your password and enter the OTP code from your email + 4. Go to Account > API Credentials to generate OAuth2 credentials + 5. Run "band auth login" with those credentials`, + Example: ` band account register --phone +19195551234 --email user@example.com --first-name John --last-name Doe`, + RunE: runRegister, +} + +func runRegister(cmd *cobra.Command, args []string) error { + accepted := registerAcceptTOS + + if !accepted { + if !cmdutil.IsInteractive() { + return fmt.Errorf("you must accept the Bandwidth Build Terms of Service to register\n\n"+ + "Review the terms at: %s\n"+ + "Then re-run with --accept-tos", tosURL) + } + + fmt.Fprintln(os.Stderr) + ui.Headerf("Bandwidth Build Terms of Service") + ui.Infof("Before registering, please review the Bandwidth Build Terms of Service:") + fmt.Fprintf(os.Stderr, "\n %s\n\n", tosURL) + + fmt.Fprint(os.Stderr, "Do you accept the Build Terms of Service? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading response: %w", err) + } + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer == "y" || answer == "yes" { + accepted = true + } + } + + if !accepted { + return fmt.Errorf("registration cancelled — you must accept the Build Terms of Service to proceed") + } + + client := api.NewClientNoAuth("https://api.bandwidth.com/v1/express") + + reqBody := map[string]interface{}{ + "phoneNumber": registerPhone, + "email": registerEmail, + "firstName": registerFirstName, + "lastName": registerLastName, + "tosAccepted": true, + } + + var result interface{} + if err := client.Post("/registration", reqBody, &result); err != nil { + return fmt.Errorf("registering account: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + if err := output.StdoutAuto(format, plain, result); err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + ui.Successf("Registration submitted!") + ui.Headerf("Next steps (complete in your browser):") + ui.Infof("1. Check your email (%s) for a registration link from Bandwidth", registerEmail) + ui.Infof("2. Enter the OTP code sent via SMS to %s", registerPhone) + ui.Infof("3. Set your password and enter the OTP code from your email") + ui.Infof("4. Go to Account > API Credentials to generate your OAuth2 credentials") + ui.Infof("5. Run: band auth login --client-id --client-secret ") + + return nil +} diff --git a/cmd/app/app.go b/cmd/app/app.go new file mode 100644 index 0000000..6d5ad45 --- /dev/null +++ b/cmd/app/app.go @@ -0,0 +1,9 @@ +package app + +import "github.com/spf13/cobra" + +// Cmd is the `band app` parent command. +var Cmd = &cobra.Command{ + Use: "app", + Short: "Manage Bandwidth applications", +} diff --git a/cmd/app/assign.go b/cmd/app/assign.go new file mode 100644 index 0000000..c06d68d --- /dev/null +++ b/cmd/app/assign.go @@ -0,0 +1,76 @@ +package app + +import ( + "fmt" + "net/url" + + "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 ( + assignSite string + assignLocation string +) + +func init() { + assignCmd.Flags().StringVar(&assignSite, "site", "", "Sub-account ID (required)") + assignCmd.Flags().StringVar(&assignLocation, "location", "", "Location (SIP peer) ID (required)") + _ = assignCmd.MarkFlagRequired("site") + _ = assignCmd.MarkFlagRequired("location") + Cmd.AddCommand(assignCmd) +} + +var assignCmd = &cobra.Command{ + Use: "assign ", + Short: "Link a messaging application to a location", + Long: `Assigns a messaging application to a location (SIP peer). All phone numbers +in that location will use this application for messaging. + +This is required before you can send messages — the from number must be in a +location that has a messaging application assigned to it.`, + Example: ` # Assign messaging app to a location + band app assign abc-123 --site 152681 --location 970014 + + # Find your site and location IDs first + band subaccount list + band location list --site `, + Args: cobra.ExactArgs(1), + RunE: runAssign, +} + +func runAssign(cmd *cobra.Command, args []string) error { + appID := args[0] + if err := cmdutil.ValidateID(appID); err != nil { + return err + } + + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + body := api.XMLBody{ + RootElement: "ApplicationsSettings", + Data: map[string]interface{}{ + "HttpMessagingV2AppId": appID, + }, + } + + path := fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/products/messaging/applicationSettings", + acctID, url.PathEscape(assignSite), url.PathEscape(assignLocation)) + + var result interface{} + if err := client.Put(path, body, &result); err != nil { + return fmt.Errorf("assigning application to location: %w", err) + } + + ui.Successf("Application %s assigned to location %s (site %s)", appID, assignLocation, assignSite) + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/app/create.go b/cmd/app/create.go new file mode 100644 index 0000000..0b4a59d --- /dev/null +++ b/cmd/app/create.go @@ -0,0 +1,120 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createName string + createType string + createCallbackURL string + createIfNotExists bool +) + +func init() { + createCmd.Flags().StringVar(&createName, "name", "", "Application name (required)") + createCmd.Flags().StringVar(&createType, "type", "", "Application type: voice or messaging (required)") + createCmd.Flags().StringVar(&createCallbackURL, "callback-url", "", "Callback URL (required)") + createCmd.Flags().BoolVar(&createIfNotExists, "if-not-exists", false, "Return existing application if one with the same name already exists") + _ = createCmd.MarkFlagRequired("name") + _ = createCmd.MarkFlagRequired("type") + _ = createCmd.MarkFlagRequired("callback-url") + Cmd.AddCommand(createCmd) +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new application", + Example: ` # Create a voice application + band app create --name "My Voice App" --type voice --callback-url https://example.com/voice + + # Create a messaging application + band app create --name "My SMS App" --type messaging --callback-url https://example.com/sms + + # Idempotent create + band app create --name "My Voice App" --type voice --callback-url https://example.com/voice --if-not-exists`, + RunE: runCreate, +} + +// CreateOpts holds the parameters for creating an application. +type CreateOpts struct { + Name string + Type string // "voice" or "messaging" + CallbackURL string +} + +// ValidateCreateOpts validates application creation options. +func ValidateCreateOpts(opts CreateOpts) error { + if opts.Type != "voice" && opts.Type != "messaging" { + return fmt.Errorf("--type must be 'voice' or 'messaging', got %q", opts.Type) + } + return nil +} + +// BuildCreateBody builds the XML request body for creating an application. +func BuildCreateBody(opts CreateOpts) map[string]interface{} { + if opts.Type == "messaging" { + return map[string]interface{}{ + "ServiceType": "Messaging-V2", + "AppName": opts.Name, + "MsgCallbackUrl": opts.CallbackURL, + "CallbackUrl": opts.CallbackURL, + } + } + return map[string]interface{}{ + "ServiceType": "Voice-V2", + "AppName": opts.Name, + "CallInitiatedCallbackUrl": opts.CallbackURL, + } +} + +func runCreate(cmd *cobra.Command, args []string) error { + opts := CreateOpts{ + Name: createName, + Type: createType, + CallbackURL: createCallbackURL, + } + if err := ValidateCreateOpts(opts); err != nil { + return err + } + + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + + if createIfNotExists { + var listResult interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/applications", acctID), &listResult); err != nil { + return fmt.Errorf("listing applications: %w", err) + } + if existing := output.FindByName(listResult, "AppName", createName); existing != nil { + return output.StdoutAuto(format, plain, existing) + } + } + + bodyData := BuildCreateBody(opts) + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/applications", acctID), api.XMLBody{RootElement: "Application", Data: bodyData}, &result); err != nil { + if strings.Contains(err.Error(), "HTTP voice feature is required") { + return fmt.Errorf("creating voice application: this account requires the HTTP Voice feature to be enabled.\n"+ + "Contact Bandwidth support to enable it, or check if your account is on the Universal Platform.\n"+ + "If you already have VCPs configured, you may need to link a voice app to them via:\n"+ + " band vcp create --name --app-id ") + } + return fmt.Errorf("creating application: %w", err) + } + + return output.StdoutAuto(format, plain, result) +} + diff --git a/cmd/app/create_test.go b/cmd/app/create_test.go new file mode 100644 index 0000000..67f6a18 --- /dev/null +++ b/cmd/app/create_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "testing" +) + +func TestValidateCreateOpts(t *testing.T) { + tests := []struct { + name string + opts CreateOpts + wantErr bool + }{ + { + name: "valid voice", + opts: CreateOpts{Name: "My App", Type: "voice", CallbackURL: "https://example.com"}, + wantErr: false, + }, + { + name: "valid messaging", + opts: CreateOpts{Name: "My App", Type: "messaging", CallbackURL: "https://example.com"}, + wantErr: false, + }, + { + name: "invalid type", + opts: CreateOpts{Name: "My App", Type: "sms", CallbackURL: "https://example.com"}, + wantErr: true, + }, + { + name: "empty type", + opts: CreateOpts{Name: "My App", Type: "", CallbackURL: "https://example.com"}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateCreateOpts(tc.opts) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestBuildCreateBody(t *testing.T) { + t.Run("voice app", func(t *testing.T) { + body := BuildCreateBody(CreateOpts{ + Name: "Voice App", + Type: "voice", + CallbackURL: "https://example.com/voice", + }) + if body["ServiceType"] != "Voice-V2" { + t.Errorf("ServiceType = %q, want Voice-V2", body["ServiceType"]) + } + if body["AppName"] != "Voice App" { + t.Errorf("AppName = %q, want Voice App", body["AppName"]) + } + if body["CallInitiatedCallbackUrl"] != "https://example.com/voice" { + t.Errorf("CallInitiatedCallbackUrl = %q, want https://example.com/voice", body["CallInitiatedCallbackUrl"]) + } + }) + + t.Run("messaging app", func(t *testing.T) { + body := BuildCreateBody(CreateOpts{ + Name: "SMS App", + Type: "messaging", + CallbackURL: "https://example.com/sms", + }) + if body["ServiceType"] != "Messaging-V2" { + t.Errorf("ServiceType = %q, want Messaging-V2", body["ServiceType"]) + } + }) +} diff --git a/cmd/app/delete.go b/cmd/app/delete.go new file mode 100644 index 0000000..05f2468 --- /dev/null +++ b/cmd/app/delete.go @@ -0,0 +1,38 @@ +package app + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { + Cmd.AddCommand(deleteCmd) +} + +var deleteCmd = &cobra.Command{ + Use: "delete [id]", + Short: "Delete an application by ID", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.Delete(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + return fmt.Errorf("deleting application: %w", err) + } + + fmt.Printf("Application %s deleted.\n", args[0]) + return nil +} diff --git a/cmd/app/get.go b/cmd/app/get.go new file mode 100644 index 0000000..6dbf623 --- /dev/null +++ b/cmd/app/get.go @@ -0,0 +1,40 @@ +package app + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get [id]", + Short: "Get an application by ID", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + return fmt.Errorf("getting application: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/app/list.go b/cmd/app/list.go new file mode 100644 index 0000000..7c0ddbe --- /dev/null +++ b/cmd/app/list.go @@ -0,0 +1,35 @@ +package app + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all applications", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/applications", acctID), &result); err != nil { + return fmt.Errorf("listing applications: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/app/peers.go b/cmd/app/peers.go new file mode 100644 index 0000000..82ba2f0 --- /dev/null +++ b/cmd/app/peers.go @@ -0,0 +1,44 @@ +package app + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(peersCmd) +} + +var peersCmd = &cobra.Command{ + Use: "peers [app-id]", + Short: "List SIP peers (locations) associated with an application", + Args: cobra.ExactArgs(1), + RunE: runPeers, +} + +func runPeers(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + path := fmt.Sprintf("/accounts/%s/applications/%s/associatedsippeers", acctID, url.PathEscape(args[0])) + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("getting application peers: %w", err) + } + + // The XML API returns an empty string when there are no peers. + // Normalize to an empty array for consistent output. + if s, ok := result.(string); ok && s == "" { + result = []interface{}{} + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/app/update.go b/cmd/app/update.go new file mode 100644 index 0000000..e01b96d --- /dev/null +++ b/cmd/app/update.go @@ -0,0 +1,142 @@ +package app + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + updateCallbackURL string +) + +func init() { + updateCmd.Flags().StringVar(&updateCallbackURL, "callback-url", "", "Callback URL for voice or messaging events") + Cmd.AddCommand(updateCmd) +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an application's settings", + Long: `Updates an existing application. Currently supports changing the callback URL. + +For messaging apps, this sets the URL where Bandwidth sends delivery status +webhooks (message-delivered, message-failed). Without a working callback URL, +you won't know whether messages were actually delivered.`, + Example: ` # Update a messaging app's callback URL + band app update abc-123 --callback-url https://your-server.example.com/callbacks`, + Args: cobra.ExactArgs(1), + RunE: runUpdate, +} + +func runUpdate(cmd *cobra.Command, args []string) error { + appID := args[0] + if err := cmdutil.ValidateID(appID); err != nil { + return err + } + if !cmd.Flags().Changed("callback-url") { + return fmt.Errorf("at least one flag must be set (e.g. --callback-url)") + } + + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + // First, get the existing app to determine its type + var existing interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), &existing); err != nil { + return fmt.Errorf("getting application: %w", err) + } + + appType := detectAppType(existing) + appName := findAppName(existing) + + var body api.XMLBody + if appType == "messaging" { + body = api.XMLBody{ + RootElement: "Application", + Data: map[string]interface{}{ + "AppName": appName, + "ServiceType": "Messaging-V2", + "MsgCallbackUrl": updateCallbackURL, + "CallbackUrl": updateCallbackURL, + }, + } + } else { + body = api.XMLBody{ + RootElement: "Application", + Data: map[string]interface{}{ + "AppName": appName, + "ServiceType": "Voice-V2", + "CallInitiatedCallbackUrl": updateCallbackURL, + }, + } + } + + var result interface{} + if err := client.Put(fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)), body, &result); err != nil { + return fmt.Errorf("updating application: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} + +// detectAppType returns "messaging" or "voice" based on the app's ServiceType field. +func detectAppType(app interface{}) string { + m, ok := app.(map[string]interface{}) + if !ok { + return "voice" + } + // Walk nested maps looking for ServiceType + return findServiceType(m) +} + +func findAppName(app interface{}) string { + m, ok := app.(map[string]interface{}) + if !ok { + return "" + } + return findStringField(m, "AppName") +} + +func findStringField(m map[string]interface{}, key string) string { + for k, v := range m { + if k == key { + if s, ok := v.(string); ok { + return s + } + } + if nested, ok := v.(map[string]interface{}); ok { + if found := findStringField(nested, key); found != "" { + return found + } + } + } + return "" +} + +func findServiceType(m map[string]interface{}) string { + for k, v := range m { + if k == "ServiceType" { + if s, ok := v.(string); ok { + if s == "Messaging-V2" { + return "messaging" + } + return "voice" + } + } + if nested, ok := v.(map[string]interface{}); ok { + if result := findServiceType(nested); result != "" { + return result + } + } + } + return "" +} diff --git a/cmd/app/update_test.go b/cmd/app/update_test.go new file mode 100644 index 0000000..de792d9 --- /dev/null +++ b/cmd/app/update_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "testing" +) + +func TestDetectAppType(t *testing.T) { + tests := []struct { + name string + app interface{} + want string + }{ + { + name: "messaging app", + app: map[string]interface{}{ + "Application": map[string]interface{}{ + "ServiceType": "Messaging-V2", + "AppName": "My SMS App", + }, + }, + want: "messaging", + }, + { + name: "voice app", + app: map[string]interface{}{ + "Application": map[string]interface{}{ + "ServiceType": "Voice-V2", + "AppName": "My Voice App", + }, + }, + want: "voice", + }, + { + name: "flat response", + app: map[string]interface{}{ + "ServiceType": "Messaging-V2", + }, + want: "messaging", + }, + { + name: "not a map", + app: "just a string", + want: "voice", // defaults to voice + }, + { + name: "nil", + app: nil, + want: "voice", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := detectAppType(tc.app) + if got != tc.want { + t.Errorf("detectAppType() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 0000000..55a8c32 --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "auth" { + t.Errorf("Use = %q, want %q", Cmd.Use, "auth") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"login", "logout", "status", "switch [account-id]", "profiles"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestTokenURLForEnvironment(t *testing.T) { + tests := []struct { + env string + want string + }{ + {"prod", "https://api.bandwidth.com"}, + {"", "https://api.bandwidth.com"}, + {"test", "https://test.api.bandwidth.com"}, + {"uat", "https://test.api.bandwidth.com"}, + {"unknown env", "https://api.bandwidth.com"}, + } + for _, tc := range tests { + t.Run(tc.env, func(t *testing.T) { + got := tokenURLForEnvironment(tc.env) + if got != tc.want { + t.Errorf("tokenURLForEnvironment(%q) = %q, want %q", tc.env, got, tc.want) + } + }) + } +} + +func TestParseJWTClaims(t *testing.T) { + claims := map[string]any{ + "accounts": []string{"9900001", "9900002"}, + "acct_scope": "9900001", + "roles": []string{"admin"}, + } + payload, _ := json.Marshal(claims) + encoded := base64.RawURLEncoding.EncodeToString(payload) + token := "eyJhbGciOiJSUzI1NiJ9." + encoded + ".fakesig" + + parsed, err := parseJWTClaims(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.AcctScope != "9900001" { + t.Errorf("AcctScope = %q, want %q", parsed.AcctScope, "9900001") + } + if len(parsed.Accounts) != 2 || parsed.Accounts[0] != "9900001" { + t.Errorf("Accounts = %v, want [9900001 9900002]", parsed.Accounts) + } +} + +func TestParseJWTClaimsInvalidFormat(t *testing.T) { + _, err := parseJWTClaims("not-a-jwt") + if err == nil { + t.Fatal("expected error for invalid JWT") + } +} + +func TestParseJWTClaimsInvalidPayload(t *testing.T) { + _, err := parseJWTClaims("header.!!!invalid-base64!!!.sig") + if err == nil { + t.Fatal("expected error for invalid base64 payload") + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go new file mode 100644 index 0000000..3355150 --- /dev/null +++ b/cmd/auth/login.go @@ -0,0 +1,275 @@ +package auth + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "github.com/spf13/cobra" + + intauth "github.com/Bandwidth/cli/internal/auth" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" +) + +// Cmd is the `band auth` parent command. +var Cmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication credentials", +} + +func init() { + Cmd.AddCommand(loginCmd) +} + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Log in with Bandwidth OAuth2 client credentials", + Long: `Authenticate with Bandwidth API client credentials. + +Only a client ID and secret are required — the CLI will automatically +discover which accounts the credentials can access. + + band auth login --client-id --client-secret + band auth login --client-id --client-secret --profile admin + +Or via environment variables: + + BW_CLIENT_ID= BW_CLIENT_SECRET= band auth login`, + RunE: runLogin, + Example: ` # Interactive login + band auth login + + # Non-interactive with flags + band auth login --client-id CLI-abc123 --client-secret mySecret + + # Non-interactive with env vars + BW_CLIENT_ID=CLI-abc123 BW_CLIENT_SECRET=mySecret band auth login + + # Store under a named profile + band auth login --client-id CLI-abc123 --client-secret mySecret --profile admin`, +} + +func init() { + loginCmd.Flags().String("client-id", "", "Bandwidth OAuth2 client ID") + loginCmd.Flags().String("client-secret", "", "Bandwidth OAuth2 client secret") + loginCmd.Flags().String("profile", "default", "Profile name to store credentials under") +} + +func runLogin(cmd *cobra.Command, args []string) error { + clientID, _ := cmd.Flags().GetString("client-id") + clientSecret, _ := cmd.Flags().GetString("client-secret") + profileName, _ := cmd.Flags().GetString("profile") + environment, _ := cmd.Root().PersistentFlags().GetString("environment") + + if clientID == "" { + clientID = os.Getenv("BW_CLIENT_ID") + } + if clientSecret == "" { + clientSecret = os.Getenv("BW_CLIENT_SECRET") + } + if environment == "" { + environment = os.Getenv("BW_ENVIRONMENT") + } + + // Prompt for missing credentials + if clientID == "" || clientSecret == "" { + if !cmdutil.IsInteractive() { + missing := []string{} + if clientID == "" { + missing = append(missing, "client-id") + } + if clientSecret == "" { + missing = append(missing, "client-secret") + } + return fmt.Errorf("no TTY available and missing: %s\n\n"+ + "Use flags:\n"+ + " band auth login --client-id ID --client-secret SECRET\n\n"+ + "Or environment variables:\n"+ + " BW_CLIENT_ID=ID BW_CLIENT_SECRET=SECRET band auth login", + strings.Join(missing, ", ")) + } + + reader := bufio.NewReader(os.Stdin) + + if clientID == "" { + fmt.Print("Client ID: ") + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading client ID: %w", err) + } + clientID = strings.TrimSpace(line) + } + + if clientSecret == "" { + fmt.Print("Client Secret: ") + secretBytes, err := cmdutil.ReadPassword() + fmt.Println() + if err != nil { + return fmt.Errorf("reading client secret: %w", err) + } + clientSecret = string(secretBytes) + } + } + + tokenURL := tokenURLForEnvironment(environment) + + // Step 1: Verify credentials + spin := ui.NewSpinner("Verifying credentials...") + spin.Start() + tm := intauth.NewTokenManager(clientID, clientSecret, tokenURL) + token, err := tm.GetToken() + spin.Stop() + if err != nil { + return fmt.Errorf("credential verification failed: %w", err) + } + ui.Successf("Credentials verified") + + // Step 2: Extract accounts and scope from JWT + claims, err := parseJWTClaims(token) + if err != nil { + return fmt.Errorf("reading token claims: %w", err) + } + accounts := claims.Accounts + + if len(accounts) == 0 && claims.AcctScope != "" { + ui.Infof("Credential scope: %s (access to all accounts)", claims.AcctScope) + ui.Infof("Use --account-id on commands to target a specific account.") + } + + // Step 3: Store secret in keychain (keyed by client ID) + if err := intauth.StorePassword(clientID, clientSecret); err != nil { + return fmt.Errorf("storing credentials: %w", err) + } + + // Step 4: Load config and create/update profile + configPath, err := config.DefaultPath() + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + profile := &config.Profile{ + ClientID: clientID, + Accounts: accounts, + Environment: environment, + } + + // Step 5: Select active account + profile.AccountID = selectAccount(cmd, accounts) + + cfg.SetProfile(profileName, profile) + + if err := config.Save(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Fprintln(os.Stderr, "") + ui.Successf("Logged in") + ui.Infof("Active account: %s", ui.ID(profile.AccountID)) + if len(accounts) > 1 { + fmt.Fprintf(os.Stderr, "\nYou have access to %d accounts. Use `band auth switch` to change the active account.\n", len(accounts)) + } + if profileName != "default" { + fmt.Fprintf(os.Stderr, "Use `band auth use %s` to switch to this profile.\n", profileName) + } + return nil +} + +// selectAccount picks an account ID from the available accounts. +func selectAccount(cmd *cobra.Command, accounts []string) string { + override, _ := cmd.Root().PersistentFlags().GetString("account-id") + if override == "" { + override = os.Getenv("BW_ACCOUNT_ID") + } + if override != "" { + for _, a := range accounts { + if a == override { + return override + } + } + fmt.Fprintf(os.Stderr, "Warning: account %s not found in token. Using it anyway.\n", override) + return override + } + + if len(accounts) == 1 { + return accounts[0] + } + if len(accounts) == 0 { + return "" + } + + if !cmdutil.IsInteractive() { + return accounts[0] + } + + fmt.Fprintf(os.Stderr, "\nYour credentials have access to %d accounts:\n\n", len(accounts)) + for i, a := range accounts { + fmt.Fprintf(os.Stderr, " %s) %s\n", ui.Bold(fmt.Sprintf("%d", i+1)), a) + } + fmt.Fprintf(os.Stderr, "\nSelect active account %s: ", ui.Muted("[1]")) + + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil || strings.TrimSpace(line) == "" { + return accounts[0] + } + + idx := 0 + if _, err := fmt.Sscanf(strings.TrimSpace(line), "%d", &idx); err != nil || idx < 1 || idx > len(accounts) { + fmt.Fprintf(os.Stderr, "Invalid selection, using account %s\n", accounts[0]) + return accounts[0] + } + return accounts[idx-1] +} + +type jwtClaims struct { + Accounts []string `json:"accounts"` + AcctScope string `json:"acct_scope"` + Roles []string `json:"roles"` +} + +func parseJWTClaims(token string) (*jwtClaims, error) { + parts := strings.SplitN(token, ".", 3) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid JWT format") + } + + payload := parts[1] + if m := len(payload) % 4; m != 0 { + payload += strings.Repeat("=", 4-m) + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("decoding JWT payload: %w", err) + } + + var claims jwtClaims + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil, fmt.Errorf("parsing JWT claims: %w", err) + } + + return &claims, nil +} + +// tokenURLForEnvironment maps an environment name to its OAuth2 base URL. +// Non-production environments can be overridden with BW_API_URL. +func tokenURLForEnvironment(env string) string { + if v := os.Getenv("BW_API_URL"); v != "" { + return strings.TrimRight(v, "/") + } + switch env { + case "test", "uat": + return "https://test.api.bandwidth.com" + default: + return "https://api.bandwidth.com" + } +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 0000000..e3729cd --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,65 @@ +package auth + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + intauth "github.com/Bandwidth/cli/internal/auth" + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" +) + +func init() { + Cmd.AddCommand(logoutCmd) +} + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Log out and remove stored credentials", + RunE: runLogout, +} + +func runLogout(cmd *cobra.Command, args []string) error { + configPath, err := config.DefaultPath() + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Collect all client IDs across profiles and legacy fields. + clientIDs := make(map[string]bool) + for _, p := range cfg.Profiles { + if p.ClientID != "" { + clientIDs[p.ClientID] = true + } + } + if cfg.ClientID != "" { + clientIDs[cfg.ClientID] = true + } + + if len(clientIDs) == 0 { + fmt.Println("Not logged in.") + return nil + } + + // Best-effort keychain deletion for every profile. + for id := range clientIDs { + if err := intauth.DeletePassword(id); err != nil { + fmt.Printf("Warning: could not remove keychain entry for %s: %v\n", id, err) + } + } + + // Remove the config file entirely for a clean slate. + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing config file: %w", err) + } + + ui.Successf("Logged out") + return nil +} diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go new file mode 100644 index 0000000..53480eb --- /dev/null +++ b/cmd/auth/profiles.go @@ -0,0 +1,115 @@ +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" +) + +func init() { + Cmd.AddCommand(profilesCmd) + Cmd.AddCommand(useCmd) +} + +var profilesCmd = &cobra.Command{ + Use: "profiles", + Short: "List all credential profiles", + RunE: runProfiles, +} + +func runProfiles(cmd *cobra.Command, args []string) error { + configPath, err := config.DefaultPath() + if err != nil { + return err + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + if len(cfg.Profiles) == 0 { + // Legacy single-credential config + if cfg.ClientID != "" { + marker := ui.Success("*") + fmt.Printf(" %s %s %s (account: %s)\n", marker, ui.Bold("default"), ui.ID(cfg.ClientID), ui.Muted(cfg.AccountID)) + } else { + fmt.Println("No profiles. Run `band auth login` to create one.") + } + return nil + } + + showEnv := cfg.HasMultipleEnvironments() + + for _, name := range cfg.ProfileNames() { + p := cfg.Profiles[name] + marker := " " + if name == cfg.ActiveProfile { + marker = ui.Success("*") + " " + } + acctInfo := ui.Muted(p.AccountID) + if len(p.Accounts) > 1 { + acctInfo = fmt.Sprintf("%s %s", ui.Muted(p.AccountID), ui.Muted(fmt.Sprintf("(%d accounts)", len(p.Accounts)))) + } + envTag := "" + if showEnv { + env := p.Environment + if env == "" { + env = "prod" + } + envTag = fmt.Sprintf(" %s", ui.Muted("["+env+"]")) + } + fmt.Printf(" %s%-10s %s %s%s\n", marker, ui.Bold(name), ui.ID(p.ClientID), acctInfo, envTag) + } + + fmt.Println("\n * = active profile") + return nil +} + +var useCmd = &cobra.Command{ + Use: "use ", + Short: "Switch to a different credential profile", + Args: cobra.ExactArgs(1), + RunE: runUse, +} + +func runUse(cmd *cobra.Command, args []string) error { + name := args[0] + + configPath, err := config.DefaultPath() + if err != nil { + return err + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + if len(cfg.Profiles) == 0 { + return fmt.Errorf("no profiles configured — run `band auth login` first") + } + + p, ok := cfg.Profiles[name] + if !ok { + fmt.Printf("Available profiles: %v\n", cfg.ProfileNames()) + return fmt.Errorf("profile %q not found", name) + } + + cfg.ActiveProfile = name + // Sync legacy fields + cfg.ClientID = p.ClientID + cfg.AccountID = p.AccountID + cfg.Accounts = p.Accounts + cfg.Environment = p.Environment + + if err := config.Save(configPath, cfg); err != nil { + return err + } + + fmt.Printf("Switched to profile %q (account: %s)\n", name, p.AccountID) + return nil +} diff --git a/cmd/auth/status.go b/cmd/auth/status.go new file mode 100644 index 0000000..fb18b5f --- /dev/null +++ b/cmd/auth/status.go @@ -0,0 +1,89 @@ +package auth + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + intauth "github.com/Bandwidth/cli/internal/auth" + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" +) + +func init() { + Cmd.AddCommand(statusCmd) +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show current authentication status", + RunE: runStatus, +} + +func runStatus(cmd *cobra.Command, args []string) error { + configPath, err := config.DefaultPath() + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + p := cfg.ActiveProfileConfig() + + if p.ClientID == "" { + fmt.Fprintln(os.Stderr, ui.Warn("Not logged in.")) + return nil + } + + env := p.Environment + if env == "" { + env = "prod" + } + + // Show environment only when it's informative: either the user is on a + // non-default environment or they have profiles spanning multiple environments. + showEnv := env != "prod" || cfg.HasMultipleEnvironments() + + _, err = intauth.GetPassword(p.ClientID) + if err != nil { + fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID)) + fmt.Printf("Account: %s\n", ui.ID(p.AccountID)) + if showEnv { + fmt.Printf("Environment: %s\n", env) + } + fmt.Println("Status: " + ui.Error("credentials not found in keychain")) + return nil + } + + profileName := cfg.ActiveProfile + if profileName == "" { + profileName = "default" + } + + fmt.Printf("Profile: %s\n", ui.Bold(profileName)) + fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID)) + if p.AccountID != "" { + fmt.Printf("Account: %s\n", ui.ID(p.AccountID)) + } else { + fmt.Printf("Account: (none — pass --account-id on commands)\n") + } + if len(p.Accounts) > 1 { + fmt.Printf("Accounts: %s\n", strings.Join(p.Accounts, ", ")) + } else if len(p.Accounts) == 0 && p.AccountID == "" { + fmt.Println("Scope: system-wide (use --account-id to target an account)") + } + if showEnv { + fmt.Printf("Environment: %s\n", env) + } + fmt.Println("Status: " + ui.Success("authenticated")) + + if len(cfg.Profiles) > 1 { + fmt.Printf("Profiles: %s\n", strings.Join(cfg.ProfileNames(), ", ")) + } + return nil +} diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go new file mode 100644 index 0000000..fe07b26 --- /dev/null +++ b/cmd/auth/switch.go @@ -0,0 +1,115 @@ +package auth + +import ( + "bufio" + "fmt" + "os" + "strings" + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" +) + +func init() { + Cmd.AddCommand(switchCmd) +} + +var switchCmd = &cobra.Command{ + Use: "switch [account-id]", + Short: "Switch the active account", + Long: `Switch between accounts accessible to your credentials. + + band auth switch # interactive selection + band auth switch 9901303 # switch directly`, + Args: cobra.MaximumNArgs(1), + RunE: runSwitch, +} + +func runSwitch(cmd *cobra.Command, args []string) error { + configPath, err := config.DefaultPath() + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if cfg.ClientID == "" { + return fmt.Errorf("not logged in — run `band auth login` first") + } + + if len(cfg.Accounts) == 0 { + return fmt.Errorf("no accounts available — try `band auth login` to refresh") + } + + var target string + + if len(args) == 1 { + // Direct switch + target = args[0] + found := false + for _, a := range cfg.Accounts { + if a == target { + found = true + break + } + } + if !found { + fmt.Fprintf(os.Stderr, "Available accounts:\n") + for _, a := range cfg.Accounts { + fmt.Fprintf(os.Stderr, " %s\n", a) + } + return fmt.Errorf("account %s not accessible with current credentials", target) + } + } else if len(cfg.Accounts) == 1 { + fmt.Printf("Only one account available: %s\n", cfg.Accounts[0]) + return nil + } else { + // Interactive selection + if !cmdutil.IsInteractive() { + fmt.Fprintf(os.Stderr, "Available accounts: %s\n", strings.Join(cfg.Accounts, ", ")) + return fmt.Errorf("specify account: band auth switch ") + } + + fmt.Fprintf(os.Stderr, "\nAvailable accounts:\n\n") + for i, a := range cfg.Accounts { + marker := " " + if a == cfg.AccountID { + marker = "* " + } + fmt.Fprintf(os.Stderr, " %s%d) %s\n", marker, i+1, a) + } + fmt.Fprintf(os.Stderr, "\n * = current\n\n") + fmt.Fprintf(os.Stderr, "Select account: ") + + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading selection: %w", err) + } + + choice := strings.TrimSpace(line) + idx := 0 + if _, err := fmt.Sscanf(choice, "%d", &idx); err != nil || idx < 1 || idx > len(cfg.Accounts) { + return fmt.Errorf("invalid selection: %s", choice) + } + target = cfg.Accounts[idx-1] + } + + if target == cfg.AccountID { + ui.Infof("Already using account %s.", ui.ID(target)) + return nil + } + + cfg.AccountID = target + if err := config.Save(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + ui.Successf("Switched to account %s", ui.ID(target)) + return nil +} diff --git a/cmd/band/main.go b/cmd/band/main.go new file mode 100644 index 0000000..1460dd7 --- /dev/null +++ b/cmd/band/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + + "github.com/Bandwidth/cli/cmd" + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(cmdutil.ExitCodeForError(err)) + } +} diff --git a/cmd/bxml/bxml.go b/cmd/bxml/bxml.go new file mode 100644 index 0000000..966cadc --- /dev/null +++ b/cmd/bxml/bxml.go @@ -0,0 +1,12 @@ +// Package bxml provides local commands for generating Bandwidth XML (BXML). +// No API calls are made — output is printed directly to stdout. +package bxml + +import "github.com/spf13/cobra" + +// Cmd is the `band bxml` parent command. +var Cmd = &cobra.Command{ + Use: "bxml", + Short: "Generate Bandwidth XML (BXML) snippets", + Long: "Generate BXML verb snippets locally. No API calls are made.", +} diff --git a/cmd/bxml/bxml_test.go b/cmd/bxml/bxml_test.go new file mode 100644 index 0000000..4298c3c --- /dev/null +++ b/cmd/bxml/bxml_test.go @@ -0,0 +1,219 @@ +package bxml + +import ( + "bytes" + "strings" + "testing" +) + +// executeCommand runs a bxml subcommand and captures stdout. +func executeCommand(args ...string) (string, error) { + buf := new(bytes.Buffer) + Cmd.SetOut(buf) + Cmd.SetErr(buf) + Cmd.SetArgs(args) + err := Cmd.Execute() + return buf.String(), err +} + +// --- speak --- + +func TestSpeakBasic(t *testing.T) { + out, err := executeCommand("speak", "Hello world") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "Hello world") { + t.Errorf("expected SpeakSentence with text, got:\n%s", out) + } + if !strings.Contains(out, ``) { + t.Errorf("expected XML declaration, got:\n%s", out) + } + if !strings.Contains(out, "") { + t.Errorf("expected Response wrapper, got:\n%s", out) + } +} + +func TestSpeakWithVoice(t *testing.T) { + out, err := executeCommand("speak", "--voice", "julie", "Press 1 for sales") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `voice="julie"`) { + t.Errorf("expected voice attribute, got:\n%s", out) + } + if !strings.Contains(out, "Press 1 for sales") { + t.Errorf("expected text content, got:\n%s", out) + } +} + +func TestSpeakXMLEscaping(t *testing.T) { + out, err := executeCommand("speak", `He said "hello" & `) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "&") && !strings.Contains(out, "&") { + t.Errorf("expected & to be escaped, got:\n%s", out) + } + if strings.Contains(out, "") && !strings.Contains(out, "<goodbye>") { + t.Errorf("expected angle brackets to be escaped, got:\n%s", out) + } +} + +func TestSpeakRequiresArg(t *testing.T) { + _, err := executeCommand("speak") + if err == nil { + t.Fatal("expected error for missing arg") + } +} + +// --- gather --- + +func TestGatherBasic(t *testing.T) { + out, err := executeCommand("gather", "--url", "https://example.com/gather") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `gatherUrl="https://example.com/gather"`) { + t.Errorf("expected gatherUrl attribute, got:\n%s", out) + } + if !strings.Contains(out, "Enter your PIN") { + t.Errorf("expected prompt SpeakSentence, got:\n%s", out) + } +} + +func TestGatherPromptEscaping(t *testing.T) { + out, err := executeCommand("gather", "--url", "https://example.com", "--prompt", "Press 1 & 2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "Press 1 & 2") { + t.Errorf("expected escaped prompt text, got:\n%s", out) + } +} + +// --- record --- + +func TestRecordBasic(t *testing.T) { + out, err := executeCommand("record") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "") { + t.Errorf("expected bare Record element, got:\n%s", out) + } +} + +func TestRecordWithOptions(t *testing.T) { + out, err := executeCommand("record", "--url", "https://example.com/done", "--max-duration", "60") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `recordCompleteUrl="https://example.com/done"`) { + t.Errorf("expected recordCompleteUrl attribute, got:\n%s", out) + } + if !strings.Contains(out, `maxDuration="60"`) { + t.Errorf("expected maxDuration attribute, got:\n%s", out) + } +} + +// --- transfer --- + +func TestTransferBasic(t *testing.T) { + out, err := executeCommand("transfer", "+19195551234") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "+19195551234") { + t.Errorf("expected PhoneNumber element, got:\n%s", out) + } + if !strings.Contains(out, "") { + t.Errorf("expected Transfer element, got:\n%s", out) + } +} + +func TestTransferWithCallerID(t *testing.T) { + out, err := executeCommand("transfer", "+19195551234", "--caller-id", "+19195550000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `transferCallerId="+19195550000"`) { + t.Errorf("expected transferCallerId attribute, got:\n%s", out) + } +} + +func TestTransferRequiresArg(t *testing.T) { + _, err := executeCommand("transfer") + if err == nil { + t.Fatal("expected error for missing phone number arg") + } +} + +// --- raw --- + +func TestRawValidXML(t *testing.T) { + xml := `Hello` + out, err := executeCommand("raw", xml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // raw pretty-prints the XML with indentation + if !strings.Contains(out, "") { + t.Errorf("expected element, got:\n%s", out) + } + if !strings.Contains(out, " Hello") { + t.Errorf("expected indented , got:\n%s", out) + } +} + +func TestRawInvalidXML(t *testing.T) { + _, err := executeCommand("raw", "") + if err == nil { + t.Fatal("expected error for invalid XML") + } + if !strings.Contains(err.Error(), "invalid XML") { + t.Errorf("expected 'invalid XML' error, got: %v", err) + } +} + +func TestRawRequiresArg(t *testing.T) { + _, err := executeCommand("raw") + if err == nil { + t.Fatal("expected error for missing arg") + } +} + +// --- xmlEscape --- + +func TestXmlEscape(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"a & b", "a & b"}, + {"", "<tag>"}, + {`"quoted"`, ""quoted""}, + {"it's", "it's"}, + } + + for _, tc := range tests { + got := xmlEscape(tc.input) + if got != tc.expected { + t.Errorf("xmlEscape(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} diff --git a/cmd/bxml/gather.go b/cmd/bxml/gather.go new file mode 100644 index 0000000..3e464ec --- /dev/null +++ b/cmd/bxml/gather.go @@ -0,0 +1,50 @@ +package bxml + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var ( + gatherURL string + gatherMaxDigits string + gatherPrompt string +) + +func init() { + gatherCmd.Flags().StringVar(&gatherURL, "url", "", "URL to send gathered digits to (required)") + gatherCmd.Flags().StringVar(&gatherMaxDigits, "max-digits", "", "Maximum number of digits to gather") + gatherCmd.Flags().StringVar(&gatherPrompt, "prompt", "", "Prompt to speak before gathering input") + _ = gatherCmd.MarkFlagRequired("url") + Cmd.AddCommand(gatherCmd) +} + +var gatherCmd = &cobra.Command{ + Use: "gather", + Short: "Generate a Gather BXML verb", + Args: cobra.NoArgs, + RunE: runGather, +} + +func runGather(cmd *cobra.Command, args []string) error { + attrs := fmt.Sprintf(`gatherUrl=%q`, gatherURL) + if gatherMaxDigits != "" { + attrs += fmt.Sprintf(` maxDigits=%q`, gatherMaxDigits) + } + + var inner string + if gatherPrompt != "" { + inner = fmt.Sprintf("\n %s\n ", xmlEscape(gatherPrompt)) + } + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString("\n") + fmt.Fprintf(&sb, " %s\n", attrs, inner) + sb.WriteString("\n") + + fmt.Fprint(cmd.OutOrStdout(), sb.String()) + return nil +} diff --git a/cmd/bxml/raw.go b/cmd/bxml/raw.go new file mode 100644 index 0000000..deb4160 --- /dev/null +++ b/cmd/bxml/raw.go @@ -0,0 +1,54 @@ +package bxml + +import ( + "encoding/xml" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func init() { + Cmd.AddCommand(rawCmd) +} + +var rawCmd = &cobra.Command{ + Use: "raw ", + Short: "Validate and pretty-print a BXML string", + Args: cobra.ExactArgs(1), + RunE: runRaw, +} + +func runRaw(cmd *cobra.Command, args []string) error { + input := args[0] + + // Validate and pretty-print by round-tripping through the XML decoder/encoder. + decoder := xml.NewDecoder(strings.NewReader(input)) + + var tokens []xml.Token + for { + tok, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return fmt.Errorf("invalid XML: %w", err) + } + tokens = append(tokens, xml.CopyToken(tok)) + } + + var buf strings.Builder + encoder := xml.NewEncoder(&buf) + encoder.Indent("", " ") + for _, tok := range tokens { + if err := encoder.EncodeToken(tok); err != nil { + return fmt.Errorf("encoding XML: %w", err) + } + } + if err := encoder.Flush(); err != nil { + return fmt.Errorf("encoding XML: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), buf.String()) + return nil +} diff --git a/cmd/bxml/record.go b/cmd/bxml/record.go new file mode 100644 index 0000000..4f81827 --- /dev/null +++ b/cmd/bxml/record.go @@ -0,0 +1,46 @@ +package bxml + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var ( + recordURL string + recordMaxDuration string +) + +func init() { + recordCmd.Flags().StringVar(&recordURL, "url", "", "URL to send recording completion event to") + recordCmd.Flags().StringVar(&recordMaxDuration, "max-duration", "", "Maximum duration of the recording in seconds") + Cmd.AddCommand(recordCmd) +} + +var recordCmd = &cobra.Command{ + Use: "record", + Short: "Generate a Record BXML verb", + Args: cobra.NoArgs, + RunE: runRecord, +} + +func runRecord(cmd *cobra.Command, args []string) error { + var attrParts []string + if recordURL != "" { + attrParts = append(attrParts, fmt.Sprintf(`recordCompleteUrl=%q`, recordURL)) + } + if recordMaxDuration != "" { + attrParts = append(attrParts, fmt.Sprintf(`maxDuration=%q`, recordMaxDuration)) + } + + var element string + if len(attrParts) > 0 { + element = fmt.Sprintf(" ", strings.Join(attrParts, " ")) + } else { + element = " " + } + + fmt.Fprintf(cmd.OutOrStdout(), "\n\n%s\n\n", element) + return nil +} diff --git a/cmd/bxml/speak.go b/cmd/bxml/speak.go new file mode 100644 index 0000000..f8b8b9c --- /dev/null +++ b/cmd/bxml/speak.go @@ -0,0 +1,48 @@ +package bxml + +import ( + "bytes" + "encoding/xml" + "fmt" + + "github.com/spf13/cobra" +) + +var speakVoice string + +func init() { + speakCmd.Flags().StringVar(&speakVoice, "voice", "", "Voice to use for speech (e.g. Susan)") + Cmd.AddCommand(speakCmd) +} + +var speakCmd = &cobra.Command{ + Use: "speak ", + Short: "Generate a SpeakSentence BXML verb", + Example: ` band bxml speak "Hello, welcome to Bandwidth." + band bxml speak --voice julie "Press 1 for sales." + band bxml speak "Goodbye." > hangup.xml`, + Args: cobra.ExactArgs(1), + RunE: runSpeak, +} + +func runSpeak(cmd *cobra.Command, args []string) error { + text := xmlEscape(args[0]) + + var inner string + if speakVoice != "" { + inner = fmt.Sprintf(` %s`, speakVoice, text) + } else { + inner = fmt.Sprintf(" %s", text) + } + + fmt.Fprintf(cmd.OutOrStdout(), "\n\n%s\n\n", inner) + return nil +} + +// xmlEscape escapes special XML characters in s so it is safe to embed in XML +// element content. +func xmlEscape(s string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} diff --git a/cmd/bxml/transfer.go b/cmd/bxml/transfer.go new file mode 100644 index 0000000..8bfea87 --- /dev/null +++ b/cmd/bxml/transfer.go @@ -0,0 +1,42 @@ +package bxml + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var transferCallerID string + +func init() { + transferCmd.Flags().StringVar(&transferCallerID, "caller-id", "", "Caller ID to use for the transfer") + Cmd.AddCommand(transferCmd) +} + +var transferCmd = &cobra.Command{ + Use: "transfer ", + Short: "Generate a Transfer BXML verb", + Args: cobra.ExactArgs(1), + RunE: runTransfer, +} + +func runTransfer(cmd *cobra.Command, args []string) error { + phoneNumber := args[0] + + var attrs string + if transferCallerID != "" { + attrs = fmt.Sprintf(` transferCallerId=%q`, transferCallerID) + } + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString("\n") + fmt.Fprintf(&sb, " \n", attrs) + fmt.Fprintf(&sb, " %s\n", xmlEscape(phoneNumber)) + sb.WriteString(" \n") + sb.WriteString("\n") + + fmt.Fprint(cmd.OutOrStdout(), sb.String()) + return nil +} diff --git a/cmd/call/call.go b/cmd/call/call.go new file mode 100644 index 0000000..fd8990f --- /dev/null +++ b/cmd/call/call.go @@ -0,0 +1,9 @@ +package call + +import "github.com/spf13/cobra" + +// Cmd is the `band call` parent command. +var Cmd = &cobra.Command{ + Use: "call", + Short: "Manage Bandwidth voice calls", +} diff --git a/cmd/call/create.go b/cmd/call/create.go new file mode 100644 index 0000000..332615e --- /dev/null +++ b/cmd/call/create.go @@ -0,0 +1,150 @@ +package call + +import ( + "errors" + "fmt" + "net/url" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createFrom string + createTo string + createAppID string + createAnswerURL string + createWait bool + createTimeout time.Duration +) + +// terminalCallStates are call states that indicate the call is finished. +// The Voice API uses: queued → initiated → answered → disconnected. +// "disconnected" is the only terminal state; the reason is in disconnectCause. +var terminalCallStates = map[string]bool{ + "disconnected": true, +} + +func init() { + createCmd.Flags().StringVar(&createFrom, "from", "", "Caller ID (required)") + createCmd.Flags().StringVar(&createTo, "to", "", "Destination number (required)") + createCmd.Flags().StringVar(&createAppID, "app-id", "", "Application ID (required)") + createCmd.Flags().StringVar(&createAnswerURL, "answer-url", "", "Answer callback URL (required)") + createCmd.Flags().BoolVar(&createWait, "wait", false, "Wait until the call reaches a terminal state") + createCmd.Flags().DurationVar(&createTimeout, "timeout", 120*time.Second, "Maximum time to wait (default 120s)") + _ = createCmd.MarkFlagRequired("from") + _ = createCmd.MarkFlagRequired("to") + _ = createCmd.MarkFlagRequired("app-id") + _ = createCmd.MarkFlagRequired("answer-url") + Cmd.AddCommand(createCmd) +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Make an outbound voice call", + Long: "Initiates an outbound voice call. The call starts dialing immediately. Bandwidth will POST to the answer-url for BXML instructions when the call connects.", + Example: ` # Fire and forget + band call create --from +19195551234 --to +15559876543 --app-id abc-123 --answer-url https://example.com/voice + + # Wait for call to complete + band call create --from +19195551234 --to +15559876543 --app-id abc-123 --answer-url https://example.com/voice --wait`, + RunE: runCreate, +} + +// CreateOpts holds the parameters for creating a call. +type CreateOpts struct { + From string + To string + AppID string + AnswerURL string +} + +// BuildCreateBody builds the request body for creating a call. +func BuildCreateBody(opts CreateOpts) map[string]string { + return map[string]string{ + "from": opts.From, + "to": opts.To, + "applicationId": opts.AppID, + "answerUrl": opts.AnswerURL, + } +} + +func runCreate(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + reqBody := BuildCreateBody(CreateOpts{ + From: createFrom, + To: createTo, + AppID: createAppID, + AnswerURL: createAnswerURL, + }) + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/calls", acctID), reqBody, &result); err != nil { + return fmt.Errorf("creating call: %w", err) + } + + if !createWait { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + // Extract the call ID from the response to poll with. + callID, err := extractCallID(result) + if err != nil { + return fmt.Errorf("--wait: could not determine call ID from response: %w", err) + } + + final, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: createTimeout, + Check: func() (bool, interface{}, error) { + var callState interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(callID)), &callState); err != nil { + // The Voice API is eventually consistent — a 404 right after + // creation means the call record hasn't propagated yet. Retry. + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return false, nil, nil + } + return false, nil, fmt.Errorf("polling call state: %w", err) + } + m, ok := callState.(map[string]interface{}) + if !ok { + return false, nil, nil + } + state, _ := m["state"].(string) + if terminalCallStates[state] { + return true, callState, nil + } + return false, nil, nil + }, + }) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, final) +} + +// extractCallID pulls the callId field out of a create-call response. +func extractCallID(result interface{}) (string, error) { + m, ok := result.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected response type %T", result) + } + for _, key := range []string{"callId", "id", "CallId"} { + if v, ok := m[key].(string); ok && v != "" { + return v, nil + } + } + return "", fmt.Errorf("callId not found in response") +} diff --git a/cmd/call/create_test.go b/cmd/call/create_test.go new file mode 100644 index 0000000..9a02295 --- /dev/null +++ b/cmd/call/create_test.go @@ -0,0 +1,96 @@ +package call + +import ( + "testing" +) + +func TestBuildCreateBody(t *testing.T) { + body := BuildCreateBody(CreateOpts{ + From: "+19195551234", + To: "+15559876543", + AppID: "abc-123", + AnswerURL: "https://example.com/voice", + }) + + if body["from"] != "+19195551234" { + t.Errorf("from = %q, want +19195551234", body["from"]) + } + if body["to"] != "+15559876543" { + t.Errorf("to = %q, want +15559876543", body["to"]) + } + if body["applicationId"] != "abc-123" { + t.Errorf("applicationId = %q, want abc-123", body["applicationId"]) + } + if body["answerUrl"] != "https://example.com/voice" { + t.Errorf("answerUrl = %q, want https://example.com/voice", body["answerUrl"]) + } +} + +func TestExtractCallID(t *testing.T) { + tests := []struct { + name string + input interface{} + want string + wantErr bool + }{ + { + name: "standard callId", + input: map[string]interface{}{"callId": "c-123-abc"}, + want: "c-123-abc", + }, + { + name: "id field", + input: map[string]interface{}{"id": "c-456-def"}, + want: "c-456-def", + }, + { + name: "CallId field", + input: map[string]interface{}{"CallId": "c-789-ghi"}, + want: "c-789-ghi", + }, + { + name: "missing callId", + input: map[string]interface{}{"status": "ok"}, + wantErr: true, + }, + { + name: "not a map", + input: "just a string", + wantErr: true, + }, + { + name: "nil", + input: nil, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := extractCallID(tc.input) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestTerminalCallStates(t *testing.T) { + if !terminalCallStates["disconnected"] { + t.Error("disconnected should be terminal") + } + for _, state := range []string{"queued", "initiated", "answered", "ringing"} { + if terminalCallStates[state] { + t.Errorf("%s should not be terminal", state) + } + } +} diff --git a/cmd/call/get.go b/cmd/call/get.go new file mode 100644 index 0000000..deecabc --- /dev/null +++ b/cmd/call/get.go @@ -0,0 +1,40 @@ +package call + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get [callId]", + Short: "Get the state of a call", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + return fmt.Errorf("getting call: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/call/hangup.go b/cmd/call/hangup.go new file mode 100644 index 0000000..c54bdde --- /dev/null +++ b/cmd/call/hangup.go @@ -0,0 +1,44 @@ +package call + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(hangupCmd) +} + +var hangupCmd = &cobra.Command{ + Use: "hangup [callId]", + Short: "Hang up an active call", + Args: cobra.ExactArgs(1), + RunE: runHangup, +} + +func runHangup(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + reqBody := map[string]string{ + "state": "completed", + } + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { + return fmt.Errorf("hanging up call: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/call/list.go b/cmd/call/list.go new file mode 100644 index 0000000..f7f7b74 --- /dev/null +++ b/cmd/call/list.go @@ -0,0 +1,35 @@ +package call + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List active and recent calls", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls", acctID), &result); err != nil { + return fmt.Errorf("listing calls: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/call/update.go b/cmd/call/update.go new file mode 100644 index 0000000..46300d4 --- /dev/null +++ b/cmd/call/update.go @@ -0,0 +1,49 @@ +package call + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var updateRedirectURL string + +func init() { + updateCmd.Flags().StringVar(&updateRedirectURL, "redirect-url", "", "URL to redirect the call to (required)") + _ = updateCmd.MarkFlagRequired("redirect-url") + Cmd.AddCommand(updateCmd) +} + +var updateCmd = &cobra.Command{ + Use: "update [callId]", + Short: "Redirect an active call to a new URL", + Args: cobra.ExactArgs(1), + RunE: runUpdate, +} + +func runUpdate(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + reqBody := map[string]string{ + "state": "active", + "redirectUrl": updateRedirectURL, + } + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s", acctID, url.PathEscape(args[0])), reqBody, &result); err != nil { + return fmt.Errorf("updating call: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/location/create.go b/cmd/location/create.go new file mode 100644 index 0000000..4db37e3 --- /dev/null +++ b/cmd/location/create.go @@ -0,0 +1,65 @@ +package location + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createSiteID string + createName string + createIfNotExists bool +) + +func init() { + createCmd.Flags().StringVar(&createSiteID, "site", "", "Sub-account ID (required)") + createCmd.Flags().StringVar(&createName, "name", "", "Location name (required)") + createCmd.Flags().BoolVar(&createIfNotExists, "if-not-exists", false, "Return existing location if one with the same name already exists") + _ = createCmd.MarkFlagRequired("site") + _ = createCmd.MarkFlagRequired("name") + Cmd.AddCommand(createCmd) +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new location (SIP peer) under a sub-account", + RunE: runCreate, +} + +func runCreate(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + + if createIfNotExists { + var listResult interface{} + listPath := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, createSiteID) + if err := client.Get(listPath, &listResult); err != nil { + return fmt.Errorf("listing locations: %w", err) + } + if existing := output.FindByName(listResult, "PeerName", createName); existing != nil { + return output.StdoutAuto(format, plain, existing) + } + } + + bodyData := map[string]interface{}{ + "PeerName": createName, + } + + var result interface{} + path := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, createSiteID) + if err := client.Post(path, api.XMLBody{RootElement: "SipPeer", Data: bodyData}, &result); err != nil { + return fmt.Errorf("creating location: %w", err) + } + + return output.StdoutAuto(format, plain, result) +} + diff --git a/cmd/location/list.go b/cmd/location/list.go new file mode 100644 index 0000000..0cc8fb5 --- /dev/null +++ b/cmd/location/list.go @@ -0,0 +1,40 @@ +package location + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var listSiteID string + +func init() { + listCmd.Flags().StringVar(&listSiteID, "site", "", "Sub-account ID (required)") + _ = listCmd.MarkFlagRequired("site") + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all locations (SIP peers) under a sub-account", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + path := fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, listSiteID) + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("listing locations: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/location/location.go b/cmd/location/location.go new file mode 100644 index 0000000..ec1683d --- /dev/null +++ b/cmd/location/location.go @@ -0,0 +1,9 @@ +package location + +import "github.com/spf13/cobra" + +// Cmd is the `band location` parent command. +var Cmd = &cobra.Command{ + Use: "location", + Short: "Manage locations (SIP peers) under sub-accounts", +} diff --git a/cmd/location/location_test.go b/cmd/location/location_test.go new file mode 100644 index 0000000..c45586e --- /dev/null +++ b/cmd/location/location_test.go @@ -0,0 +1,37 @@ +package location + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "location" { + t.Errorf("Use = %q, want %q", Cmd.Use, "location") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"create", "list"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestCreateRequiredFlags(t *testing.T) { + for _, flag := range []string{"site", "name"} { + f := createCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + } + } +} + +func TestListRequiredFlags(t *testing.T) { + f := listCmd.Flags().Lookup("site") + if f == nil { + t.Error("missing flag \"site\"") + } +} diff --git a/cmd/message/get.go b/cmd/message/get.go new file mode 100644 index 0000000..c04a2a4 --- /dev/null +++ b/cmd/message/get.go @@ -0,0 +1,49 @@ +package message + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get [messageId]", + Short: "Get message metadata by ID", + Long: "Retrieves metadata for a specific message. Note: Bandwidth does not store message content — only metadata (timestamps, direction, segment count) is returned.", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/users/%s/messages?messageId=%s", acctID, url.QueryEscape(args[0])), &result); err != nil { + return fmt.Errorf("getting message: %w", err) + } + + // The API returns a search wrapper { "messages": [...], "pageInfo": {}, "totalCount": 1 }. + // Unwrap to return just the single message object. + if m, ok := result.(map[string]interface{}); ok { + if msgs, ok := m["messages"].([]interface{}); ok && len(msgs) == 1 { + result = msgs[0] + } + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/message/list.go b/cmd/message/list.go new file mode 100644 index 0000000..e67f18b --- /dev/null +++ b/cmd/message/list.go @@ -0,0 +1,103 @@ +package message + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + listTo string + listFrom string + listStartDate string + listEndDate string +) + +func init() { + listCmd.Flags().StringVar(&listTo, "to", "", "Filter by recipient phone number") + listCmd.Flags().StringVar(&listFrom, "from", "", "Filter by sender phone number") + listCmd.Flags().StringVar(&listStartDate, "start-date", "", "Filter messages after this date (e.g. 2024-01-01T00:00:00.000Z)") + listCmd.Flags().StringVar(&listEndDate, "end-date", "", "Filter messages before this date (e.g. 2024-01-31T23:59:59.000Z)") + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List messages with optional filters", + Long: "Lists message metadata with optional filters by recipient, sender, and date range. Note: Bandwidth does not store message content — only metadata is returned.", + Example: ` # Filter by sender + band message list --from +15559876543 + + # Filter by date range + band message list --start-date 2024-01-01T00:00:00.000Z --end-date 2024-01-31T23:59:59.000Z`, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + // Build query string from filters + // The Bandwidth messaging search API uses sourceTn/destinationTn, not from/to. + // Phone numbers must be URL-encoded (the + sign becomes %2B). + var params []string + if listTo != "" { + params = append(params, "destinationTn="+url.QueryEscape(listTo)) + } + if listFrom != "" { + params = append(params, "sourceTn="+url.QueryEscape(listFrom)) + } + if listStartDate != "" { + params = append(params, "fromDateTime="+listStartDate) + } + if listEndDate != "" { + params = append(params, "toDateTime="+listEndDate) + } + + path := fmt.Sprintf("/users/%s/messages", acctID) + if len(params) > 0 { + path += "?" + strings.Join(params, "&") + } + + var result interface{} + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("listing messages: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, extractMessages(result)) +} + +// extractMessages unwraps the Bandwidth messaging search response to return +// just the messages array. The raw API response deserializes as either: +// - a map with a "messages" key, or +// - an array whose first element is such a map. +// +// If the structure doesn't match, the original result is returned as-is. +func extractMessages(result interface{}) interface{} { + // Try direct map with "messages" key. + if m, ok := result.(map[string]interface{}); ok { + if msgs, exists := m["messages"]; exists { + return msgs + } + return result + } + + // Try array wrapping a map with "messages" key. + if arr, ok := result.([]interface{}); ok && len(arr) > 0 { + if m, ok := arr[0].(map[string]interface{}); ok { + if msgs, exists := m["messages"]; exists { + return msgs + } + } + } + + return result +} diff --git a/cmd/message/list_test.go b/cmd/message/list_test.go new file mode 100644 index 0000000..a50e6ae --- /dev/null +++ b/cmd/message/list_test.go @@ -0,0 +1,87 @@ +package message + +import ( + "reflect" + "testing" +) + +func TestExtractMessages(t *testing.T) { + msgs := []interface{}{ + map[string]interface{}{"messageId": "msg-1", "from": "+15551234567"}, + map[string]interface{}{"messageId": "msg-2", "from": "+15559876543"}, + } + + t.Run("direct map with messages key", func(t *testing.T) { + input := map[string]interface{}{ + "messages": msgs, + "pageInfo": map[string]interface{}{}, + "totalCount": float64(2), + } + got := extractMessages(input) + if !reflect.DeepEqual(got, msgs) { + t.Errorf("got %v, want %v", got, msgs) + } + }) + + t.Run("array wrapping map with messages key", func(t *testing.T) { + input := []interface{}{ + map[string]interface{}{ + "messages": msgs, + "pageInfo": map[string]interface{}{}, + "totalCount": float64(2), + }, + } + got := extractMessages(input) + if !reflect.DeepEqual(got, msgs) { + t.Errorf("got %v, want %v", got, msgs) + } + }) + + t.Run("map without messages key returns as-is", func(t *testing.T) { + input := map[string]interface{}{"other": "data"} + got := extractMessages(input) + if !reflect.DeepEqual(got, input) { + t.Errorf("got %v, want %v", got, input) + } + }) + + t.Run("empty array returns as-is", func(t *testing.T) { + input := []interface{}{} + got := extractMessages(input) + if !reflect.DeepEqual(got, input) { + t.Errorf("got %v, want %v", got, input) + } + }) + + t.Run("nil returns nil", func(t *testing.T) { + got := extractMessages(nil) + if got != nil { + t.Errorf("got %v, want nil", got) + } + }) + + t.Run("string returns as-is", func(t *testing.T) { + got := extractMessages("unexpected") + if got != "unexpected" { + t.Errorf("got %v, want 'unexpected'", got) + } + }) + + t.Run("array wrapping non-map returns as-is", func(t *testing.T) { + input := []interface{}{"not a map"} + got := extractMessages(input) + if !reflect.DeepEqual(got, input) { + t.Errorf("got %v, want %v", got, input) + } + }) + + t.Run("messages value is nil", func(t *testing.T) { + input := map[string]interface{}{ + "messages": nil, + } + got := extractMessages(input) + if got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} diff --git a/cmd/message/media/delete.go b/cmd/message/media/delete.go new file mode 100644 index 0000000..759e100 --- /dev/null +++ b/cmd/message/media/delete.go @@ -0,0 +1,38 @@ +package media + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/ui" +) + +func init() { + Cmd.AddCommand(deleteCmd) +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a media file", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.Delete(fmt.Sprintf("/users/%s/media/%s", acctID, args[0]), nil); err != nil { + return fmt.Errorf("deleting media: %w", err) + } + + ui.Successf("Deleted media: %s", args[0]) + return nil +} diff --git a/cmd/message/media/get.go b/cmd/message/media/get.go new file mode 100644 index 0000000..12eb382 --- /dev/null +++ b/cmd/message/media/get.go @@ -0,0 +1,47 @@ +package media + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var getOutput string + +func init() { + getCmd.Flags().StringVar(&getOutput, "output", "", "File path to write the media to (required)") + _ = getCmd.MarkFlagRequired("output") + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Download a media file", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + data, err := client.GetRaw(fmt.Sprintf("/users/%s/media/%s", acctID, args[0])) + if err != nil { + return fmt.Errorf("downloading media: %w", err) + } + + if err := os.WriteFile(getOutput, data, 0644); err != nil { + return fmt.Errorf("writing media to file: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Media saved to %s\n", getOutput) + return nil +} diff --git a/cmd/message/media/list.go b/cmd/message/media/list.go new file mode 100644 index 0000000..ebe49a1 --- /dev/null +++ b/cmd/message/media/list.go @@ -0,0 +1,35 @@ +package media + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List uploaded media files", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/users/%s/media", acctID), &result); err != nil { + return fmt.Errorf("listing media: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/message/media/media.go b/cmd/message/media/media.go new file mode 100644 index 0000000..668b0e0 --- /dev/null +++ b/cmd/message/media/media.go @@ -0,0 +1,9 @@ +package media + +import "github.com/spf13/cobra" + +// Cmd is the `band message media` parent command. +var Cmd = &cobra.Command{ + Use: "media", + Short: "Manage MMS media files", +} diff --git a/cmd/message/media/media_test.go b/cmd/message/media/media_test.go new file mode 100644 index 0000000..3185654 --- /dev/null +++ b/cmd/message/media/media_test.go @@ -0,0 +1,75 @@ +package media + +import ( + "mime" + "path/filepath" + "testing" +) + +// TestContentTypeDetection verifies that the MIME type auto-detection logic +// used by the upload command works for common media types. +func TestContentTypeDetection(t *testing.T) { + tests := []struct { + filename string + want string + }{ + {"image.png", "image/png"}, + {"photo.jpg", "image/jpeg"}, + {"photo.jpeg", "image/jpeg"}, + {"animation.gif", "image/gif"}, + {"document.pdf", "application/pdf"}, + {"audio.mp3", "audio/mpeg"}, + {"video.mp4", "video/mp4"}, + {"data.json", "application/json"}, + } + + for _, tc := range tests { + t.Run(tc.filename, func(t *testing.T) { + got := mime.TypeByExtension(filepath.Ext(tc.filename)) + if got == "" { + t.Skipf("MIME type not registered for %s on this OS", tc.filename) + } + if got != tc.want { + t.Errorf("TypeByExtension(%q) = %q, want %q", tc.filename, got, tc.want) + } + }) + } +} + +// TestMediaIDFromFilename verifies the default media ID derivation logic. +func TestMediaIDFromFilename(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/Users/me/photos/image.png", "image.png"}, + {"image.png", "image.png"}, + {"./relative/path/photo.jpg", "photo.jpg"}, + {"/deeply/nested/dir/file.mp4", "file.mp4"}, + } + + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + got := filepath.Base(tc.path) + if got != tc.want { + t.Errorf("filepath.Base(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} + +// TestFallbackContentType verifies unknown extensions get application/octet-stream. +func TestFallbackContentType(t *testing.T) { + got := mime.TypeByExtension(".xyz123unknown") + if got != "" { + t.Skipf("unexpectedly got MIME type %q for unknown extension", got) + } + // The upload command falls back to application/octet-stream when TypeByExtension returns "" + fallback := "application/octet-stream" + if got == "" { + got = fallback + } + if got != "application/octet-stream" { + t.Errorf("fallback = %q, want application/octet-stream", got) + } +} diff --git a/cmd/message/media/upload.go b/cmd/message/media/upload.go new file mode 100644 index 0000000..0624346 --- /dev/null +++ b/cmd/message/media/upload.go @@ -0,0 +1,79 @@ +package media + +import ( + "fmt" + "mime" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/ui" +) + +var ( + uploadMediaID string + uploadContentType string +) + +func init() { + uploadCmd.Flags().StringVar(&uploadMediaID, "media-id", "", "Media identifier/filename on Bandwidth (defaults to local filename)") + uploadCmd.Flags().StringVar(&uploadContentType, "content-type", "", "MIME type (auto-detected from file extension if omitted)") + Cmd.AddCommand(uploadCmd) +} + +var uploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Upload a media file for MMS", + Long: "Uploads a local file to Bandwidth's media storage for use in MMS messages. The resulting media URL can be passed to `band message send --media`.", + Example: ` # Upload with auto-detected content type + band message media upload image.png + + # Upload with custom media ID + band message media upload photo.jpg --media-id my-campaign-image.jpg`, + Args: cobra.ExactArgs(1), + RunE: runUpload, +} + +func runUpload(cmd *cobra.Command, args []string) error { + filePath := args[0] + + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + // Determine media ID (default to filename) + mediaID := uploadMediaID + if mediaID == "" { + mediaID = filepath.Base(filePath) + } + if err := cmdutil.ValidateID(mediaID); err != nil { + return fmt.Errorf("invalid media ID: %w", err) + } + + // Determine content type + ct := uploadContentType + if ct == "" { + ct = mime.TypeByExtension(filepath.Ext(filePath)) + if ct == "" { + ct = "application/octet-stream" + } + } + + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.PutRaw(fmt.Sprintf("/users/%s/media/%s", acctID, mediaID), data, ct); err != nil { + return fmt.Errorf("uploading media: %w", err) + } + + // Print the media URL that can be used with `message send --media` + mediaURL := fmt.Sprintf("https://messaging.bandwidth.com/api/v2/users/%s/media/%s", acctID, mediaID) + fmt.Fprintln(cmd.OutOrStdout(), mediaURL) + ui.Successf("Use with: band message send --media %s", mediaURL) + return nil +} diff --git a/cmd/message/message.go b/cmd/message/message.go new file mode 100644 index 0000000..8e880fe --- /dev/null +++ b/cmd/message/message.go @@ -0,0 +1,17 @@ +package message + +import ( + "github.com/spf13/cobra" + + mediacmd "github.com/Bandwidth/cli/cmd/message/media" +) + +// Cmd is the `band message` parent command. +var Cmd = &cobra.Command{ + Use: "message", + Short: "Send and manage SMS/MMS messages", +} + +func init() { + Cmd.AddCommand(mediacmd.Cmd) +} diff --git a/cmd/message/preflight.go b/cmd/message/preflight.go new file mode 100644 index 0000000..6be2ded --- /dev/null +++ b/cmd/message/preflight.go @@ -0,0 +1,406 @@ +package message + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" +) + +// PreflightResult describes whether a number is ready to send messages. +type PreflightResult struct { + Ready bool + NumberType cmdutil.NumberType + CampaignID string // non-empty if assigned to a 10DLC campaign + Message string // human-readable status +} + +// CheckCallbackURL verifies that the messaging application has a callback URL +// that looks like a real server. Without one, delivery confirmations are lost. +func CheckCallbackURL(dashClient *api.Client, acctID, appID string) string { + var result interface{} + path := fmt.Sprintf("/accounts/%s/applications/%s", acctID, url.PathEscape(appID)) + if err := dashClient.Get(path, &result); err != nil { + return "" // can't check, don't warn + } + + callbackURL := findCallbackURL(result) + if callbackURL == "" || isPlaceholderURL(callbackURL) { + return fmt.Sprintf("message not sent — your messaging application has no working callback URL.\n"+ + "Without a callback server, delivery failures are invisible and messages can silently disappear.\n"+ + "Set a callback URL: band app update %s --callback-url https://your-server.example.com/callbacks", appID) + } + return "" +} + +func findCallbackURL(resp interface{}) string { + data, err := json.Marshal(resp) + if err != nil { + return "" + } + // Look for MsgCallbackUrl first (messaging apps), then CallbackUrl + var urls []string + findFieldValues(data, "MsgCallbackUrl", &urls) + if len(urls) > 0 && urls[0] != "" { + return urls[0] + } + findFieldValues(data, "CallbackUrl", &urls) + if len(urls) > 0 { + return urls[0] + } + return "" +} + +func isPlaceholderURL(u string) bool { + placeholders := []string{ + "example.com", + "localhost", + "127.0.0.1", + "google.com", + "bandwidth.com", + } + for _, p := range placeholders { + if strings.Contains(u, p) { + return true + } + } + return false +} + +// CheckAppAssociation verifies that a messaging application is linked to at +// least one location (SIP peer). If it has no associations, messages sent +// through it will silently vanish — 202 accepted but never delivered. +// +// It checks both the app's associatedsippeers endpoint AND each location's +// applicationSettings (the assignment may only be visible from the location side). +func CheckAppAssociation(dashClient *api.Client, acctID, appID string) (bool, string) { + // First try the app-level query (fast path) + var peersResult interface{} + path := fmt.Sprintf("/accounts/%s/applications/%s/associatedsippeers", acctID, url.PathEscape(appID)) + if err := dashClient.Get(path, &peersResult); err == nil { + peers := extractAssociatedPeers(peersResult) + if len(peers) > 0 { + return true, "" + } + } + + // App-level query found nothing — check from the location side. + // List all sites, then check each location's messaging applicationSettings. + var sitesResult interface{} + if err := dashClient.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &sitesResult); err != nil { + return true, "" // can't check, don't block + } + + siteIDs := extractSiteIDs(sitesResult) + for _, siteID := range siteIDs { + var locsResult interface{} + if err := dashClient.Get(fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), &locsResult); err != nil { + continue + } + peerIDs := extractPeerIDs(locsResult) + for _, peerID := range peerIDs { + var settings interface{} + settingsPath := fmt.Sprintf("/accounts/%s/sites/%s/sippeers/%s/products/messaging/applicationSettings", acctID, siteID, peerID) + if err := dashClient.Get(settingsPath, &settings); err != nil { + continue + } + if foundAppID := extractAppIDFromSettings(settings); foundAppID == appID { + return true, "" + } + } + } + + return false, fmt.Sprintf("messaging application %s is not linked to any location — messages will silently fail.\n"+ + "Fix: band app assign %s --site --location \n"+ + "Find IDs: band subaccount list && band location list --site ", appID, appID) +} + +// CheckMessagingReadiness verifies that a phone number is properly provisioned +// for messaging. For 10DLC numbers, it checks campaign assignment via the +// tendlc API. For toll-free and short codes, it returns advisory messages +// since those checks require credentials we may not have. +func CheckMessagingReadiness(platClient *api.Client, acctID, fromNumber string) PreflightResult { + nt := cmdutil.ClassifyNumber(fromNumber) + + switch nt { + case cmdutil.NumberType10DLC: + return check10DLC(platClient, acctID, fromNumber) + case cmdutil.NumberTypeTollFree: + return checkTollFree(platClient, acctID, fromNumber) + case cmdutil.NumberTypeShortCode: + return PreflightResult{ + Ready: true, // we can't check, assume provisioned + NumberType: nt, + Message: "short code — check carrier status with: band shortcode get " + fromNumber, + } + default: + return PreflightResult{Ready: true, NumberType: nt} + } +} + +// check10DLC iterates the account's 10DLC campaigns and checks if the number +// is assigned to any of them with SUCCESS status. +func check10DLC(platClient *api.Client, acctID, number string) PreflightResult { + result := PreflightResult{NumberType: cmdutil.NumberType10DLC} + + // Normalize to E.164 for the filter param + e164 := number + if !strings.HasPrefix(e164, "+") { + e164 = "+" + e164 + } + + // List all campaigns + var campaignsResp interface{} + if err := platClient.Get(fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns", acctID), &campaignsResp); err != nil { + // Can't check — don't block the send, just warn + result.Ready = true + result.Message = "could not verify campaign assignment (API error) — ensure the number is on an approved campaign" + return result + } + + campaigns := extractCampaigns(campaignsResp) + if len(campaigns) == 0 { + result.Ready = false + result.Message = "no 10DLC campaigns found on this account — the number must be assigned to an approved campaign before messages will deliver.\n" + + "Check registration: band tendlc campaigns" + return result + } + + // Check each active campaign for this phone number + for _, c := range campaigns { + if c.status != "REGISTERED" { + continue + } + var pnResp interface{} + path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns/%s/phoneNumbers?phoneNumber=%s", + acctID, url.PathEscape(c.id), url.QueryEscape(e164)) + if err := platClient.Get(path, &pnResp); err != nil { + continue + } + if pn := findPhoneNumberInResponse(pnResp, e164); pn != nil { + if pn.status == "SUCCESS" { + result.Ready = true + result.CampaignID = c.id + result.Message = fmt.Sprintf("number is assigned to campaign %s (status: SUCCESS)", c.id) + return result + } + // Found but not SUCCESS — still provisioning + result.Ready = false + result.CampaignID = c.id + result.Message = fmt.Sprintf("number is on campaign %s but status is %q — it may not be fully provisioned yet", c.id, pn.status) + return result + } + } + + // Not found on any campaign + result.Ready = false + result.Message = fmt.Sprintf("number is not assigned to any active 10DLC campaign — delivery will fail (error 4476).\n"+ + "Check registration status: band tendlc number %s\n"+ + "List campaigns: band tendlc campaigns\n"+ + "Assign to a campaign: band tnoption assign %s --campaign-id ", number, number) + return result +} + +func checkTollFree(platClient *api.Client, acctID, number string) PreflightResult { + result := PreflightResult{NumberType: cmdutil.NumberTypeTollFree} + + e164 := number + if !strings.HasPrefix(e164, "+") { + e164 = "+" + e164 + } + + var tfvResp interface{} + if err := platClient.Get(fmt.Sprintf("/api/v2/accounts/%s/phoneNumbers/%s/tollFreeVerification", acctID, url.PathEscape(e164)), &tfvResp); err != nil { + // 403 means the credential doesn't have TFV access — don't block, just advise + if apiErr, ok := err.(*api.APIError); ok && apiErr.StatusCode == 403 { + result.Ready = true + result.Message = "toll-free verification status could not be checked (insufficient permissions) — ensure TFV is approved" + return result + } + result.Ready = true + result.Message = "toll-free verification status could not be checked — ensure TFV is approved before sending" + return result + } + + status := extractTFVStatus(tfvResp) + switch strings.ToUpper(status) { + case "VERIFIED": + result.Ready = true + result.Message = "toll-free verification: VERIFIED" + case "": + result.Ready = true + result.Message = "toll-free verification status unknown — ensure TFV is approved" + default: + result.Ready = false + result.Message = fmt.Sprintf("toll-free verification status is %q — must be VERIFIED before messages will deliver.\n"+ + "Check status: band tfv get %s", status, number) + } + return result +} + +// --- response parsing helpers --- + +type campaignInfo struct { + id string + status string +} + +type phoneNumberInfo struct { + number string + status string +} + +func extractCampaigns(resp interface{}) []campaignInfo { + data, err := json.Marshal(resp) + if err != nil { + return nil + } + var parsed struct { + Data []struct { + CampaignID string `json:"campaignId"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return nil + } + var result []campaignInfo + for _, c := range parsed.Data { + result = append(result, campaignInfo{id: c.CampaignID, status: c.Status}) + } + return result +} + +func findPhoneNumberInResponse(resp interface{}, e164 string) *phoneNumberInfo { + data, err := json.Marshal(resp) + if err != nil { + return nil + } + var parsed struct { + Data []struct { + PhoneNumber string `json:"phoneNumber"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return nil + } + for _, pn := range parsed.Data { + if pn.PhoneNumber == e164 { + return &phoneNumberInfo{number: pn.PhoneNumber, status: pn.Status} + } + } + return nil +} + +func extractSiteIDs(resp interface{}) []string { + data, err := json.Marshal(resp) + if err != nil { + return nil + } + var ids []string + findFieldValues(data, "Id", &ids) + return ids +} + +func extractPeerIDs(resp interface{}) []string { + data, err := json.Marshal(resp) + if err != nil { + return nil + } + var ids []string + findFieldValues(data, "PeerId", &ids) + return ids +} + +func extractAppIDFromSettings(resp interface{}) string { + data, err := json.Marshal(resp) + if err != nil { + return "" + } + var ids []string + findFieldValues(data, "HttpMessagingV2AppId", &ids) + if len(ids) > 0 { + return ids[0] + } + return "" +} + +func findFieldValues(data []byte, fieldName string, values *[]string) { + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return + } + for k, v := range m { + if k == fieldName { + if s, ok := v.(string); ok && s != "" { + *values = append(*values, s) + } + } + if nested, ok := v.(map[string]interface{}); ok { + d, _ := json.Marshal(nested) + findFieldValues(d, fieldName, values) + } + if arr, ok := v.([]interface{}); ok { + for _, item := range arr { + d, _ := json.Marshal(item) + findFieldValues(d, fieldName, values) + } + } + } +} + +func extractAssociatedPeers(resp interface{}) []string { + data, err := json.Marshal(resp) + if err != nil { + return nil + } + // The response is XML-parsed, so it could be nested in various wrapper keys. + // Look for any PeerId values recursively. + var ids []string + findPeerIDs(data, &ids) + return ids +} + +func findPeerIDs(data []byte, ids *[]string) { + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return + } + for k, v := range m { + if k == "PeerId" || k == "peerId" { + if s, ok := v.(string); ok && s != "" { + *ids = append(*ids, s) + } + } + // Recurse into nested maps + if nested, ok := v.(map[string]interface{}); ok { + d, _ := json.Marshal(nested) + findPeerIDs(d, ids) + } + // Recurse into arrays + if arr, ok := v.([]interface{}); ok { + for _, item := range arr { + d, _ := json.Marshal(item) + findPeerIDs(d, ids) + } + } + } +} + +func extractTFVStatus(resp interface{}) string { + data, err := json.Marshal(resp) + if err != nil { + return "" + } + var parsed struct { + Status string `json:"status"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return "" + } + return parsed.Status +} diff --git a/cmd/message/preflight_test.go b/cmd/message/preflight_test.go new file mode 100644 index 0000000..dfca1b3 --- /dev/null +++ b/cmd/message/preflight_test.go @@ -0,0 +1,272 @@ +package message + +import ( + "testing" +) + +func TestIsPlaceholderURL(t *testing.T) { + tests := []struct { + url string + want bool + }{ + {"https://example.com/callbacks", true}, + {"https://www.example.com/hooks", true}, + {"http://localhost:3000/callbacks", true}, + {"http://127.0.0.1:8080/hooks", true}, + {"https://google.com", true}, + {"https://bandwidth.com", true}, + {"https://my-app.herokuapp.com/callbacks", false}, + {"https://api.mycompany.com/webhooks/bandwidth", false}, + {"https://hooks.slack.com/services/abc", false}, + {"", false}, // empty is handled separately by the caller + } + + for _, tc := range tests { + t.Run(tc.url, func(t *testing.T) { + got := isPlaceholderURL(tc.url) + if got != tc.want { + t.Errorf("isPlaceholderURL(%q) = %v, want %v", tc.url, got, tc.want) + } + }) + } +} + +func TestExtractCampaigns(t *testing.T) { + t.Run("valid response", func(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"campaignId": "CR8HFN0", "status": "REGISTERED"}, + map[string]interface{}{"campaignId": "CERLUDZ", "status": "DECLINED"}, + }, + } + campaigns := extractCampaigns(resp) + if len(campaigns) != 2 { + t.Fatalf("expected 2 campaigns, got %d", len(campaigns)) + } + if campaigns[0].id != "CR8HFN0" || campaigns[0].status != "REGISTERED" { + t.Errorf("campaigns[0] = %+v, want CR8HFN0/REGISTERED", campaigns[0]) + } + if campaigns[1].id != "CERLUDZ" || campaigns[1].status != "DECLINED" { + t.Errorf("campaigns[1] = %+v, want CERLUDZ/DECLINED", campaigns[1]) + } + }) + + t.Run("empty data", func(t *testing.T) { + resp := map[string]interface{}{"data": []interface{}{}} + campaigns := extractCampaigns(resp) + if len(campaigns) != 0 { + t.Errorf("expected 0 campaigns, got %d", len(campaigns)) + } + }) + + t.Run("nil response", func(t *testing.T) { + campaigns := extractCampaigns(nil) + if campaigns != nil { + t.Errorf("expected nil, got %v", campaigns) + } + }) +} + +func TestFindPhoneNumberInResponse(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"phoneNumber": "+17752345103", "status": "SUCCESS"}, + map[string]interface{}{"phoneNumber": "+17752345191", "status": "PENDING"}, + }, + } + + t.Run("found with SUCCESS", func(t *testing.T) { + pn := findPhoneNumberInResponse(resp, "+17752345103") + if pn == nil { + t.Fatal("expected to find phone number") + } + if pn.status != "SUCCESS" { + t.Errorf("status = %q, want SUCCESS", pn.status) + } + }) + + t.Run("found with PENDING", func(t *testing.T) { + pn := findPhoneNumberInResponse(resp, "+17752345191") + if pn == nil { + t.Fatal("expected to find phone number") + } + if pn.status != "PENDING" { + t.Errorf("status = %q, want PENDING", pn.status) + } + }) + + t.Run("not found", func(t *testing.T) { + pn := findPhoneNumberInResponse(resp, "+19195551234") + if pn != nil { + t.Errorf("expected nil, got %+v", pn) + } + }) + + t.Run("empty response", func(t *testing.T) { + empty := map[string]interface{}{"data": []interface{}{}} + pn := findPhoneNumberInResponse(empty, "+17752345103") + if pn != nil { + t.Errorf("expected nil, got %+v", pn) + } + }) +} + +func TestExtractTFVStatus(t *testing.T) { + tests := []struct { + name string + resp interface{} + want string + }{ + { + name: "verified", + resp: map[string]interface{}{"status": "VERIFIED", "phoneNumber": "+18005551234"}, + want: "VERIFIED", + }, + { + name: "pending", + resp: map[string]interface{}{"status": "PENDING"}, + want: "PENDING", + }, + { + name: "empty response", + resp: map[string]interface{}{}, + want: "", + }, + { + name: "nil", + resp: nil, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractTFVStatus(tc.resp) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestExtractSiteIDs(t *testing.T) { + // Simulates the XML-parsed sites response shape + resp := map[string]interface{}{ + "SitesResponse": map[string]interface{}{ + "Sites": map[string]interface{}{ + "Site": map[string]interface{}{ + "Id": "152681", + "Name": "Subacct", + }, + }, + }, + } + ids := extractSiteIDs(resp) + if len(ids) != 1 || ids[0] != "152681" { + t.Errorf("got %v, want [152681]", ids) + } +} + +func TestExtractPeerIDs(t *testing.T) { + // Simulates XML-parsed SIP peers response with multiple peers + resp := map[string]interface{}{ + "TNSipPeersResponse": map[string]interface{}{ + "SipPeers": map[string]interface{}{ + "SipPeer": []interface{}{ + map[string]interface{}{"PeerId": "970014", "PeerName": "Test"}, + map[string]interface{}{"PeerId": "1072011", "PeerName": "Other"}, + }, + }, + }, + } + ids := extractPeerIDs(resp) + if len(ids) != 2 { + t.Fatalf("expected 2 peer IDs, got %d: %v", len(ids), ids) + } + if ids[0] != "970014" || ids[1] != "1072011" { + t.Errorf("got %v, want [970014, 1072011]", ids) + } +} + +func TestExtractAppIDFromSettings(t *testing.T) { + resp := map[string]interface{}{ + "ApplicationsSettingsResponse": map[string]interface{}{ + "ApplicationsSettings": map[string]interface{}{ + "HttpMessagingV2AppId": "298e5e78-1c5f-4cc7-af8d-5c77cf2fb84c", + }, + }, + } + got := extractAppIDFromSettings(resp) + if got != "298e5e78-1c5f-4cc7-af8d-5c77cf2fb84c" { + t.Errorf("got %q, want 298e5e78-1c5f-4cc7-af8d-5c77cf2fb84c", got) + } +} + +func TestExtractAppIDFromSettings_Empty(t *testing.T) { + resp := map[string]interface{}{} + got := extractAppIDFromSettings(resp) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestFindCallbackURL(t *testing.T) { + t.Run("messaging app", func(t *testing.T) { + resp := map[string]interface{}{ + "Application": map[string]interface{}{ + "MsgCallbackUrl": "https://myserver.com/callbacks", + "CallbackUrl": "https://myserver.com/callbacks", + "ServiceType": "Messaging-V2", + }, + } + got := findCallbackURL(resp) + if got != "https://myserver.com/callbacks" { + t.Errorf("got %q, want https://myserver.com/callbacks", got) + } + }) + + t.Run("no callback URL", func(t *testing.T) { + resp := map[string]interface{}{ + "Application": map[string]interface{}{ + "ServiceType": "Messaging-V2", + }, + } + got := findCallbackURL(resp) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) + + t.Run("nil", func(t *testing.T) { + got := findCallbackURL(nil) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + +func TestExtractAssociatedPeers(t *testing.T) { + t.Run("with peers", func(t *testing.T) { + resp := map[string]interface{}{ + "AssociatedSipPeers": map[string]interface{}{ + "AssociatedSipPeer": map[string]interface{}{ + "SiteId": "152681", + "PeerId": "970014", + "PeerName": "Test", + }, + }, + } + peers := extractAssociatedPeers(resp) + if len(peers) != 1 || peers[0] != "970014" { + t.Errorf("got %v, want [970014]", peers) + } + }) + + t.Run("empty", func(t *testing.T) { + resp := map[string]interface{}{} + peers := extractAssociatedPeers(resp) + if len(peers) != 0 { + t.Errorf("got %v, want empty", peers) + } + }) +} diff --git a/cmd/message/send.go b/cmd/message/send.go new file mode 100644 index 0000000..de21afa --- /dev/null +++ b/cmd/message/send.go @@ -0,0 +1,178 @@ +package message + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" + "github.com/Bandwidth/cli/internal/ui" +) + +var ( + sendTo []string + sendFrom string + sendText string + sendMedia []string + sendAppID string + sendTag string + sendPriority string + sendExpiration string + sendStdin bool +) + +func init() { + sendCmd.Flags().StringSliceVar(&sendTo, "to", nil, "Recipient phone number(s) in E.164 format (required, repeatable)") + sendCmd.Flags().StringVar(&sendFrom, "from", "", "Sender phone number in E.164 format (required)") + sendCmd.Flags().StringVar(&sendText, "text", "", "Message body text") + sendCmd.Flags().StringSliceVar(&sendMedia, "media", nil, "Media URL(s) for MMS (repeatable)") + sendCmd.Flags().StringVar(&sendAppID, "app-id", "", "Bandwidth messaging application ID (required)") + sendCmd.Flags().StringVar(&sendTag, "tag", "", "Custom tag included in callback events (max 1024 chars)") + sendCmd.Flags().StringVar(&sendPriority, "priority", "", "Message priority: default or high") + sendCmd.Flags().StringVar(&sendExpiration, "expiration", "", "Message expiration as RFC-3339 datetime") + sendCmd.Flags().BoolVar(&sendStdin, "stdin", false, "Read message body from stdin") + _ = sendCmd.MarkFlagRequired("to") + _ = sendCmd.MarkFlagRequired("from") + _ = sendCmd.MarkFlagRequired("app-id") + Cmd.AddCommand(sendCmd) +} + +var sendCmd = &cobra.Command{ + Use: "send", + Short: "Send an SMS or MMS message", + Long: "Sends an SMS or MMS message. The message is queued for delivery (202 Accepted) — actual delivery status arrives via webhook callbacks on your application.", + Example: ` # Send an SMS + band message send --to +15551234567 --from +15559876543 --app-id abc-123 --text "Hello world" + + # Send an MMS with media + band message send --to +15551234567 --from +15559876543 --app-id abc-123 --text "Check this out" --media https://example.com/image.png + + # Group message + band message send --to +15551234567,+15552345678 --from +15559876543 --app-id abc-123 --text "Hey everyone" + + # Pipe message body from stdin + echo "Hello from a script" | band message send --to +15551234567 --from +15559876543 --app-id abc-123 --stdin`, + RunE: runSend, +} + +// SendOpts holds the parameters for sending a message. +type SendOpts struct { + To []string + From string + Text string + Media []string + AppID string + Tag string + Priority string + Expiration string +} + +// ValidateSendOpts validates the send options before making the API call. +func ValidateSendOpts(opts SendOpts) error { + if opts.Text == "" && len(opts.Media) == 0 { + return fmt.Errorf("at least one of text or media is required") + } + if opts.Priority != "" && opts.Priority != "default" && opts.Priority != "high" { + return fmt.Errorf("priority must be \"default\" or \"high\"") + } + return nil +} + +// BuildSendBody builds the request body for sending a message. +func BuildSendBody(opts SendOpts) map[string]interface{} { + body := map[string]interface{}{ + "to": opts.To, + "from": opts.From, + "applicationId": opts.AppID, + } + if opts.Text != "" { + body["text"] = opts.Text + } + if len(opts.Media) > 0 { + body["media"] = opts.Media + } + if opts.Tag != "" { + body["tag"] = opts.Tag + } + if opts.Priority != "" { + body["priority"] = opts.Priority + } + if opts.Expiration != "" { + body["expiration"] = opts.Expiration + } + return body +} + +func runSend(cmd *cobra.Command, args []string) error { + text := sendText + + // Read from stdin if requested + if sendStdin { + if term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("--stdin was set but stdin is a terminal — pipe input or use --text instead") + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + text = string(data) + } + + opts := SendOpts{ + To: sendTo, + From: sendFrom, + Text: text, + Media: sendMedia, + AppID: sendAppID, + Tag: sendTag, + Priority: sendPriority, + Expiration: sendExpiration, + } + if err := ValidateSendOpts(opts); err != nil { + return err + } + + // Preflight: verify the messaging app is linked to a location. + dashClient, dashAcctID, dashErr := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if dashErr == nil { + if ok, msg := CheckAppAssociation(dashClient, dashAcctID, sendAppID); !ok { + return fmt.Errorf("preflight check failed: %s", msg) + } + // Block if the callback URL looks fake/missing — without it, delivery + // failures are invisible and you won't know messages aren't arriving. + if warning := CheckCallbackURL(dashClient, dashAcctID, sendAppID); warning != "" { + return fmt.Errorf("preflight check failed: %s", warning) + } + } + + // Preflight: verify the from number's provisioning (campaign, TFV, etc.). + platClient, platAcctID, platErr := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if platErr == nil { + check := CheckMessagingReadiness(platClient, platAcctID, sendFrom) + if !check.Ready { + return fmt.Errorf("preflight check failed: %s", check.Message) + } + if check.Message != "" { + ui.Infof("%s", check.Message) + } + } + + client, acctID, err := cmdutil.MessagingClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + reqBody := BuildSendBody(opts) + + var result interface{} + if err := client.Post(fmt.Sprintf("/users/%s/messages", acctID), reqBody, &result); err != nil { + return fmt.Errorf("sending message: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/message/send_test.go b/cmd/message/send_test.go new file mode 100644 index 0000000..08bde9b --- /dev/null +++ b/cmd/message/send_test.go @@ -0,0 +1,206 @@ +package message + +import ( + "testing" +) + +func TestValidateSendOpts(t *testing.T) { + tests := []struct { + name string + opts SendOpts + wantErr bool + }{ + { + name: "valid SMS", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Hello", + }, + }, + { + name: "valid MMS media only", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Media: []string{"https://example.com/img.png"}, + }, + }, + { + name: "valid with text and media", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Look at this", Media: []string{"https://example.com/img.png"}, + }, + }, + { + name: "no text and no media", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", + }, + wantErr: true, + }, + { + name: "invalid priority", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Hello", Priority: "urgent", + }, + wantErr: true, + }, + { + name: "valid priority default", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Hello", Priority: "default", + }, + }, + { + name: "valid priority high", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Hello", Priority: "high", + }, + }, + { + name: "empty priority is valid", + opts: SendOpts{ + To: []string{"+15551234567"}, From: "+15559876543", + AppID: "abc-123", Text: "Hello", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateSendOpts(tc.opts) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestBuildSendBody(t *testing.T) { + t.Run("minimal SMS", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567"}, + From: "+15559876543", + AppID: "abc-123", + Text: "Hello world", + }) + + if body["from"] != "+15559876543" { + t.Errorf("from = %q, want +15559876543", body["from"]) + } + if body["applicationId"] != "abc-123" { + t.Errorf("applicationId = %q, want abc-123", body["applicationId"]) + } + if body["text"] != "Hello world" { + t.Errorf("text = %q, want Hello world", body["text"]) + } + to, ok := body["to"].([]string) + if !ok || len(to) != 1 || to[0] != "+15551234567" { + t.Errorf("to = %v, want [+15551234567]", body["to"]) + } + // Optional fields should be absent + if _, ok := body["media"]; ok { + t.Error("media should not be present for SMS") + } + if _, ok := body["tag"]; ok { + t.Error("tag should not be present when empty") + } + if _, ok := body["priority"]; ok { + t.Error("priority should not be present when empty") + } + if _, ok := body["expiration"]; ok { + t.Error("expiration should not be present when empty") + } + }) + + t.Run("MMS with media only", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567"}, + From: "+15559876543", + AppID: "abc-123", + Media: []string{"https://example.com/image.png"}, + }) + + media, ok := body["media"].([]string) + if !ok || len(media) != 1 || media[0] != "https://example.com/image.png" { + t.Errorf("media = %v, want [https://example.com/image.png]", body["media"]) + } + if _, ok := body["text"]; ok { + t.Error("text should not be present when empty") + } + }) + + t.Run("MMS with multiple media", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567"}, + From: "+15559876543", + AppID: "abc-123", + Text: "Multiple attachments", + Media: []string{"https://example.com/a.png", "https://example.com/b.jpg"}, + }) + + media, ok := body["media"].([]string) + if !ok || len(media) != 2 { + t.Errorf("expected 2 media URLs, got %v", body["media"]) + } + }) + + t.Run("group message", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567", "+15552345678", "+15553456789"}, + From: "+15559876543", + AppID: "abc-123", + Text: "Hey everyone", + }) + + to, ok := body["to"].([]string) + if !ok || len(to) != 3 { + t.Errorf("to should have 3 recipients, got %v", body["to"]) + } + }) + + t.Run("all optional fields", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567"}, + From: "+15559876543", + AppID: "abc-123", + Text: "Hello", + Media: []string{"https://example.com/img.png"}, + Tag: "my-tag", + Priority: "high", + Expiration: "2025-01-01T00:00:00Z", + }) + + if body["tag"] != "my-tag" { + t.Errorf("tag = %q, want my-tag", body["tag"]) + } + if body["priority"] != "high" { + t.Errorf("priority = %q, want high", body["priority"]) + } + if body["expiration"] != "2025-01-01T00:00:00Z" { + t.Errorf("expiration = %q, want 2025-01-01T00:00:00Z", body["expiration"]) + } + }) + + t.Run("required fields always present", func(t *testing.T) { + body := BuildSendBody(SendOpts{ + To: []string{"+15551234567"}, + From: "+15559876543", + AppID: "abc-123", + Text: "test", + }) + + for _, key := range []string{"to", "from", "applicationId"} { + if _, ok := body[key]; !ok { + t.Errorf("required field %q missing from body", key) + } + } + }) +} diff --git a/cmd/number/get.go b/cmd/number/get.go new file mode 100644 index 0000000..3b57fa9 --- /dev/null +++ b/cmd/number/get.go @@ -0,0 +1,77 @@ +package number + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get voice configuration details for a phone number", + Long: "Returns a phone number's voice settings including its Voice Configuration Package assignment. The number must be in E.164 format.", + Example: ` band number get +19195551234 + band number get +19195551234 --plain`, + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + phoneNumber := args[0] + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + // The API doesn't support filtering by phone number, so we paginate + // through all voice numbers and find the match client-side. + cursor := "" + for { + path := fmt.Sprintf("/v2/accounts/%s/phoneNumbers/voice?limit=1000", acctID) + if cursor != "" { + path += "&afterCursor=" + cursor + } + + var raw interface{} + if err := client.Get(path, &raw); err != nil { + return fmt.Errorf("getting phone number details: %w", err) + } + + // Search through the data array for our number. + if m, ok := raw.(map[string]interface{}); ok { + if data, ok := m["data"].([]interface{}); ok { + for _, item := range data { + if rec, ok := item.(map[string]interface{}); ok { + if rec["phoneNumber"] == phoneNumber { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, item) + } + } + } + // Check for next page. + if len(data) < 1000 { + break + } + } else { + break + } + if page, ok := m["page"].(map[string]interface{}); ok { + if next, ok := page["afterCursor"].(string); ok && next != "" { + cursor = next + continue + } + } + } + break + } + + return fmt.Errorf("phone number %s not found or has no voice configuration", phoneNumber) +} diff --git a/cmd/number/list.go b/cmd/number/list.go new file mode 100644 index 0000000..20bf5b7 --- /dev/null +++ b/cmd/number/list.go @@ -0,0 +1,149 @@ +package number + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var listStatus string + +func init() { + listCmd.Flags().StringVar(&listStatus, "status", "Inservice", + "Comma-separated statuses to include. Common values: Inservice (live), "+ + "InAccount (assigned, not yet live), Aging (released, in aging period).") + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List phone numbers on the account", + Long: `Lists phone numbers on the active account. + +By default, returns only numbers in service (ready to route calls or send +messages). Pass --status to include numbers in other states. + +Examples: + band number list # default: only in-service + band number list --status Inservice,InAccount # include numbers just ordered + band number list --status Aging # numbers being released`, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + numbers, err := fetchAccountNumbers(client, acctID, listStatus) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, numbers) +} + +// tnsMaxPageSize is the largest page size /tns accepts. The endpoint rejects +// size > 2500 with error 1006. We paginate internally if an account has more. +const tnsMaxPageSize = 2500 + +// tnsMaxPages caps how many pages we'll fetch as a safety net. 2500 * 100 = +// 250k numbers is well beyond any realistic account size; exceeding this +// almost certainly means a bug or a broken server loop. +const tnsMaxPages = 100 + +// fetchAccountNumbers queries /tns for numbers on acctID matching the given +// comma-separated status filter, paginating as needed, and returns their +// FullNumbers formatted as E.164 strings. /tns is preferred over +// /accounts/{id}/inserviceNumbers because it's accessible to credentials +// without the inservice role. +func fetchAccountNumbers(client *api.Client, acctID, status string) ([]string, error) { + var all []string + for page := 1; page <= tnsMaxPages; page++ { + q := url.Values{} + q.Set("accountId", acctID) + q.Set("status", status) + q.Set("size", strconv.Itoa(tnsMaxPageSize)) + q.Set("page", strconv.Itoa(page)) + + var result interface{} + if err := client.Get("/tns?"+q.Encode(), &result); err != nil { + return nil, wrapTNsError(err, acctID) + } + + batch := extractFullNumbers(result) + all = append(all, batch...) + if len(batch) < tnsMaxPageSize { + return all, nil + } + } + return nil, fmt.Errorf("listing phone numbers: exceeded %d pages (%d numbers); "+ + "narrow the query with --status or contact support", + tnsMaxPages, tnsMaxPages*tnsMaxPageSize) +} + +// wrapTNsError annotates /tns errors with actionable context. The endpoint +// returns an empty body on 403, so the raw APIError message is just +// "API error 403:" — not useful to the user. The most common 403 cases are +// Build (express) credentials, which don't yet include the Numbers role +// (coming in a future Build update), and regular credentials missing the role. +func wrapTNsError(err error, acctID string) error { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { + return fmt.Errorf("listing phone numbers: credential lacks the Numbers role on account %s.\n"+ + "Build credentials don't include this role yet — it'll be added in an upcoming\n"+ + "Build update. In the meantime, your pre-provisioned number is visible in the\n"+ + "Bandwidth account portal and is already wired to the default voice application.\n"+ + "For non-Build accounts, contact your Bandwidth account manager to grant the\n"+ + "Numbers role: %w", acctID, err) + } + return fmt.Errorf("listing phone numbers: %w", err) +} + +// extractFullNumbers walks a decoded /tns response and returns each +// TelephoneNumber's FullNumber formatted as E.164. +func extractFullNumbers(raw interface{}) []string { + var out []string + collectFullNumbers(raw, &out) + return out +} + +func collectFullNumbers(v interface{}, out *[]string) { + switch x := v.(type) { + case map[string]interface{}: + if fn, ok := x["FullNumber"].(string); ok && fn != "" { + *out = append(*out, normalizeE164(fn)) + return + } + for _, child := range x { + collectFullNumbers(child, out) + } + case []interface{}: + for _, item := range x { + collectFullNumbers(item, out) + } + } +} + +// normalizeE164 returns n in E.164 form. /tns may return either a 10-digit +// US number ("9195551234") or an already-prefixed value depending on whether +// the account is on the v2 E.164 response format. +func normalizeE164(n string) string { + if strings.HasPrefix(n, "+") { + return n + } + if len(n) == 10 { + return "+1" + n + } + return "+" + n +} diff --git a/cmd/number/number.go b/cmd/number/number.go new file mode 100644 index 0000000..4b9864f --- /dev/null +++ b/cmd/number/number.go @@ -0,0 +1,9 @@ +package number + +import "github.com/spf13/cobra" + +// Cmd is the `band number` parent command. +var Cmd = &cobra.Command{ + Use: "number", + Short: "Manage Bandwidth phone numbers", +} diff --git a/cmd/number/number_test.go b/cmd/number/number_test.go new file mode 100644 index 0000000..e8bda60 --- /dev/null +++ b/cmd/number/number_test.go @@ -0,0 +1,165 @@ +package number + +import ( + "errors" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func TestBuildOrderBody(t *testing.T) { + body := BuildOrderBody([]string{"+19195551234", "+19195551235"}) + + tnList, ok := body["TelephoneNumberList"].(map[string]interface{}) + if !ok { + t.Fatal("TelephoneNumberList is not a map") + } + numbers, ok := tnList["TelephoneNumber"].([]string) + if !ok { + t.Fatal("TelephoneNumber is not []string") + } + if len(numbers) != 2 { + t.Errorf("expected 2 numbers, got %d", len(numbers)) + } + if numbers[0] != "+19195551234" { + t.Errorf("numbers[0] = %q, want +19195551234", numbers[0]) + } + if numbers[1] != "+19195551235" { + t.Errorf("numbers[1] = %q, want +19195551235", numbers[1]) + } +} + +func TestBuildOrderBody_SingleNumber(t *testing.T) { + body := BuildOrderBody([]string{"+19195551234"}) + tnList := body["TelephoneNumberList"].(map[string]interface{}) + numbers := tnList["TelephoneNumber"].([]string) + if len(numbers) != 1 { + t.Errorf("expected 1 number, got %d", len(numbers)) + } +} + +func TestNormalizeE164(t *testing.T) { + cases := []struct { + in, want string + }{ + {"+19195551234", "+19195551234"}, // already E.164 + {"9195551234", "+19195551234"}, // 10-digit US + {"19195551234", "+19195551234"}, // 11-digit, no + + {"442071838750", "+442071838750"}, + } + for _, c := range cases { + got := normalizeE164(c.in) + if got != c.want { + t.Errorf("normalizeE164(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestExtractFullNumbers_MultiResult(t *testing.T) { + // Shape produced by XMLToMap for /tns with 2 results. + raw := map[string]interface{}{ + "TelephoneNumbersResponse": map[string]interface{}{ + "TelephoneNumberCount": "2", + "Links": map[string]interface{}{ + "first": "...", + "next": "...", + }, + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": []interface{}{ + map[string]interface{}{ + "City": "CARY", + "FullNumber": "2012381139", + "Status": "Inservice", + }, + map[string]interface{}{ + "City": "CARY", + "FullNumber": "+19192381138", + "Status": "Inservice", + }, + }, + }, + }, + } + got := extractFullNumbers(raw) + want := map[string]bool{"+12012381139": true, "+19192381138": true} + if len(got) != len(want) { + t.Fatalf("got %d numbers, want %d: %v", len(got), len(want), got) + } + for _, n := range got { + if !want[n] { + t.Errorf("unexpected number %q", n) + } + } +} + +func TestExtractFullNumbers_SingleResult(t *testing.T) { + // XMLToMap produces a single map (not array) when only one result. + raw := map[string]interface{}{ + "TelephoneNumbersResponse": map[string]interface{}{ + "TelephoneNumberCount": "1", + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": map[string]interface{}{ + "City": "CARY", + "FullNumber": "2012381139", + "Status": "Inservice", + }, + }, + }, + } + got := extractFullNumbers(raw) + if len(got) != 1 || got[0] != "+12012381139" { + t.Errorf("got %v, want [+12012381139]", got) + } +} + +func TestExtractFullNumbers_Empty(t *testing.T) { + raw := map[string]interface{}{ + "TelephoneNumbersResponse": map[string]interface{}{ + "TelephoneNumberCount": "0", + }, + } + got := extractFullNumbers(raw) + if len(got) != 0 { + t.Errorf("expected empty, got %v", got) + } +} + +func TestWrapTNsError_403(t *testing.T) { + apiErr := &api.APIError{StatusCode: 403, Body: ""} + err := wrapTNsError(apiErr, "9901409") + if err == nil { + t.Fatal("expected non-nil error") + } + msg := err.Error() + if !strings.Contains(msg, "9901409") { + t.Errorf("error should name the account id, got %q", msg) + } + if !strings.Contains(msg, "Numbers role") { + t.Errorf("error should mention missing role, got %q", msg) + } + if !strings.Contains(msg, "Build credentials") { + t.Errorf("error should mention Build credentials, got %q", msg) + } + // Must preserve the underlying APIError for exit-code mapping. + var unwrapped *api.APIError + if !errors.As(err, &unwrapped) || unwrapped.StatusCode != 403 { + t.Errorf("wrapped error should unwrap to APIError 403") + } +} + +func TestWrapTNsError_NonAPIError(t *testing.T) { + err := wrapTNsError(errors.New("network down"), "9901409") + if !strings.Contains(err.Error(), "network down") { + t.Errorf("should pass through non-API error, got %q", err.Error()) + } +} + +func TestWrapTNsError_500(t *testing.T) { + // Non-403 API errors should pass through without the 403-specific message. + apiErr := &api.APIError{StatusCode: 500, Body: "server broke"} + err := wrapTNsError(apiErr, "9901409") + if strings.Contains(err.Error(), "Build credentials") { + t.Errorf("500 should not get the 403 message, got %q", err.Error()) + } +} diff --git a/cmd/number/order.go b/cmd/number/order.go new file mode 100644 index 0000000..1406067 --- /dev/null +++ b/cmd/number/order.go @@ -0,0 +1,97 @@ +package number + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + orderWait bool + orderTimeout time.Duration +) + +func init() { + orderCmd.Flags().BoolVar(&orderWait, "wait", false, "Wait until the ordered number(s) appear in service") + orderCmd.Flags().DurationVar(&orderTimeout, "timeout", 30*time.Second, "Maximum time to wait (default 30s)") + Cmd.AddCommand(orderCmd) +} + +var orderCmd = &cobra.Command{ + Use: "order [number...]", + Short: "Order one or more phone numbers", + Long: "Orders one or more phone numbers from a search result. Use --wait to block until the numbers are active.", + Example: ` band number order +19195551234 + band number order +19195551234 +19195551235 + band number order +19195551234 --wait --timeout 30s`, + Args: cobra.MinimumNArgs(1), + RunE: runOrder, +} + +// BuildOrderBody builds the XML request body for ordering phone numbers. +func BuildOrderBody(numbers []string) map[string]interface{} { + return map[string]interface{}{ + "TelephoneNumberList": map[string]interface{}{ + "TelephoneNumber": numbers, + }, + } +} + +func runOrder(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + bodyData := BuildOrderBody(args) + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/orders", acctID), api.XMLBody{RootElement: "Order", Data: bodyData}, &result); err != nil { + return fmt.Errorf("ordering numbers: %w", err) + } + + if !orderWait { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + // Poll until all ordered numbers appear as Inservice. InAccount means the + // number is assigned but not yet routable, so we wait for Inservice only — + // otherwise --wait would return too early. + ordered := make(map[string]bool, len(args)) + for _, n := range args { + ordered[normalizeE164(n)] = true + } + + final, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: orderTimeout, + Check: func() (bool, interface{}, error) { + nums, err := fetchAccountNumbers(client, acctID, "Inservice") + if err != nil { + return false, nil, fmt.Errorf("polling in-service numbers: %w", err) + } + found := 0 + for _, n := range nums { + if ordered[n] { + found++ + } + } + if found >= len(args) { + return true, nums, nil + } + return false, nil, nil + }, + }) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, final) +} diff --git a/cmd/number/release.go b/cmd/number/release.go new file mode 100644 index 0000000..76272ee --- /dev/null +++ b/cmd/number/release.go @@ -0,0 +1,43 @@ +package number + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(releaseCmd) +} + +var releaseCmd = &cobra.Command{ + Use: "release [number]", + Short: "Release a phone number", + Args: cobra.ExactArgs(1), + RunE: runRelease, +} + +func runRelease(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + bodyData := map[string]interface{}{ + "TelephoneNumberList": map[string]interface{}{ + "TelephoneNumber": []string{args[0]}, + }, + } + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/disconnects", acctID), api.XMLBody{RootElement: "DisconnectTelephoneNumberOrder", Data: bodyData}, &result); err != nil { + return fmt.Errorf("releasing number: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/number/search.go b/cmd/number/search.go new file mode 100644 index 0000000..df0d43a --- /dev/null +++ b/cmd/number/search.go @@ -0,0 +1,58 @@ +package number + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + searchAreaCode string + searchQuantity string +) + +func init() { + searchCmd.Flags().StringVar(&searchAreaCode, "area-code", "", "Area code to search (required)") + searchCmd.Flags().StringVar(&searchQuantity, "quantity", "10", "Number of results to return") + _ = searchCmd.MarkFlagRequired("area-code") + Cmd.AddCommand(searchCmd) +} + +var searchCmd = &cobra.Command{ + Use: "search", + Short: "Search available phone numbers", + Long: "Searches for available phone numbers that can be ordered. Results are not reserved — order promptly.", + Example: ` # Search by area code + band number search --area-code 919 + + # Limit results + band number search --area-code 704 --quantity 3 + + # Agent-friendly: get just the numbers + band number search --area-code 919 --plain`, + RunE: runSearch, +} + +func runSearch(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + q := url.Values{} + q.Set("areaCode", searchAreaCode) + q.Set("quantity", searchQuantity) + + var result interface{} + path := fmt.Sprintf("/accounts/%s/availableNumbers?%s", acctID, q.Encode()) + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("searching available numbers: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/quickstart/quickstart.go b/cmd/quickstart/quickstart.go new file mode 100644 index 0000000..c680ebe --- /dev/null +++ b/cmd/quickstart/quickstart.go @@ -0,0 +1,366 @@ +package quickstart + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/ui" +) + +var ( + qsCallbackURL string + qsAreaCode string + qsName string + qsLegacy bool +) + +// Cmd is the `band quickstart` command. +var Cmd = &cobra.Command{ + Use: "quickstart", + Short: "One-command setup: create app, VCP, and order a phone number", + Long: `Quickstart creates everything you need to make voice calls. + +By default, it uses the Universal Platform path (VCP). If your account +is on the legacy platform, use --legacy for the sub-account/location path.`, + Example: ` # Universal Platform (default) + band quickstart --callback-url https://example.com/voice + + # Legacy platform + band quickstart --callback-url https://example.com/voice --legacy + + # Custom area code and name + band quickstart --callback-url https://example.com/voice --area-code 704 --name "Demo"`, + RunE: runQuickstart, +} + +func init() { + Cmd.Flags().StringVar(&qsCallbackURL, "callback-url", "", "URL for voice callbacks (required)") + Cmd.Flags().StringVar(&qsAreaCode, "area-code", "919", "Area code to search for a number") + Cmd.Flags().StringVar(&qsName, "name", "Quickstart", "Name prefix for created resources") + Cmd.Flags().BoolVar(&qsLegacy, "legacy", false, "Use legacy sub-account/location provisioning") + _ = Cmd.MarkFlagRequired("callback-url") +} + +type quickstartResult struct { + Status string `json:"status"` + AppID string `json:"appId,omitempty"` + VCPID string `json:"vcpId,omitempty"` + SiteID string `json:"siteId,omitempty"` + SIPPeerID string `json:"sipPeerId,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + CallbackURL string `json:"callbackUrl"` + Path string `json:"path"` // "vcp" or "legacy" +} + +func runQuickstart(cmd *cobra.Command, args []string) error { + if qsLegacy { + return runLegacyQuickstart(cmd) + } + return runVCPQuickstart(cmd) +} + +func runVCPQuickstart(cmd *cobra.Command) error { + // We need both a Dashboard (XML) client for apps and a Platform (JSON) client for VCPs + dashClient, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + platClient, _, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + result := quickstartResult{CallbackURL: qsCallbackURL, Path: "vcp"} + + // Step 1: Create voice application + appSpin := ui.NewSpinner("Creating voice application...") + appSpin.Start() + var appResp interface{} + appBody := api.XMLBody{ + RootElement: "Application", + Data: map[string]interface{}{ + "ServiceType": "Voice-V2", + "AppName": qsName + " App", + "CallInitiatedCallbackUrl": qsCallbackURL, + }, + } + appErr := dashClient.Post(fmt.Sprintf("/accounts/%s/applications", acctID), appBody, &appResp) + appSpin.Stop() + if appErr != nil { + // If voice app creation fails with 409, suggest --legacy + fmt.Fprintf(os.Stderr, "\nVoice application creation failed. If this is a legacy account, try:\n") + fmt.Fprintf(os.Stderr, " band quickstart --callback-url %s --legacy\n\n", qsCallbackURL) + return fmt.Errorf("creating voice application: %w", appErr) + } + appID := extractIDFromResponse(appResp, "ApplicationId", "applicationId") + result.AppID = appID + ui.Successf("Application: %s", ui.ID(appID)) + + // Step 2: Create VCP linked to the app + vcpSpin := ui.NewSpinner("Creating Voice Configuration Package...") + vcpSpin.Start() + var vcpResp interface{} + vcpBody := map[string]interface{}{ + "name": qsName + " VCP", + "httpVoiceV2ApplicationId": appID, + } + vcpErr := platClient.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), vcpBody, &vcpResp) + vcpSpin.Stop() + if vcpErr != nil { + fmt.Fprintf(os.Stderr, "\nVCP creation failed. If this is a legacy account, try:\n") + fmt.Fprintf(os.Stderr, " band quickstart --callback-url %s --legacy\n\n", qsCallbackURL) + return fmt.Errorf("creating VCP: %w", vcpErr) + } + vcpID := extractIDFromResponse(vcpResp, "voiceConfigurationPackageId") + result.VCPID = vcpID + ui.Successf("VCP: %s", ui.ID(vcpID)) + + // Step 3: Search and order a number + phoneNumber, err := searchAndOrderNumber(dashClient, acctID) + if err != nil { + result.Status = "complete_no_number" + ui.Warnf("%v", err) + } else { + result.PhoneNumber = phoneNumber + ui.Successf("Number: %s", ui.ID(phoneNumber)) + + // Step 4: Assign number to VCP + assignSpin := ui.NewSpinner("Assigning number to VCP...") + assignSpin.Start() + assignBody := map[string]interface{}{ + "action": "ADD", + "phoneNumbers": []string{phoneNumber}, + } + var assignResp interface{} + assignErr := platClient.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), assignBody, &assignResp) + assignSpin.Stop() + if assignErr != nil { + ui.Warnf("Failed to assign number to VCP: %v", assignErr) + } else { + ui.Successf("Number assigned to VCP") + } + + result.Status = "complete" + } + + fmt.Fprintln(os.Stderr, "") + ui.Headerf("Next steps") + fmt.Fprintf(os.Stderr, " 1. Start your callback server at %s\n", qsCallbackURL) + if result.PhoneNumber != "" { + fmt.Fprintf(os.Stderr, " 2. band call create --from %s --to --app-id %s --answer-url %s\n", + result.PhoneNumber, appID, qsCallbackURL) + } + + return printResult(result) +} + +func runLegacyQuickstart(cmd *cobra.Command) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + result := quickstartResult{CallbackURL: qsCallbackURL, Path: "legacy"} + + // Step 1: Create sub-account + siteSpin := ui.NewSpinner("Creating sub-account...") + siteSpin.Start() + var siteResp interface{} + siteBody := api.XMLBody{ + RootElement: "Site", + Data: map[string]interface{}{"Name": qsName + " Sub-account"}, + } + siteErr := client.Post(fmt.Sprintf("/accounts/%s/sites", acctID), siteBody, &siteResp) + siteSpin.Stop() + if siteErr != nil { + return fmt.Errorf("creating sub-account: %w", siteErr) + } + siteID := extractIDFromResponse(siteResp, "Id", "id", "siteId") + result.SiteID = siteID + ui.Successf("Sub-account: %s", ui.ID(siteID)) + + // Step 2: Create SIP peer + sipSpin := ui.NewSpinner("Creating location...") + sipSpin.Start() + var sipResp interface{} + sipBody := api.XMLBody{ + RootElement: "SipPeer", + Data: map[string]interface{}{ + "PeerName": qsName + " Location", + "IsDefaultPeer": "true", + }, + } + sipErr := client.Post(fmt.Sprintf("/accounts/%s/sites/%s/sippeers", acctID, siteID), sipBody, &sipResp) + sipSpin.Stop() + if sipErr != nil { + return fmt.Errorf("creating location: %w", sipErr) + } + sipPeerID := extractIDFromResponse(sipResp, "PeerId", "Id", "id") + result.SIPPeerID = sipPeerID + ui.Successf("Location: %s", ui.ID(sipPeerID)) + + // Step 3: Create voice application + appSpin := ui.NewSpinner("Creating voice application...") + appSpin.Start() + var appResp interface{} + appBody := api.XMLBody{ + RootElement: "Application", + Data: map[string]interface{}{ + "ServiceType": "Voice-V2", + "AppName": qsName + " App", + "CallInitiatedCallbackUrl": qsCallbackURL, + }, + } + appErr := client.Post(fmt.Sprintf("/accounts/%s/applications", acctID), appBody, &appResp) + appSpin.Stop() + if appErr != nil { + return fmt.Errorf("creating application: %w", appErr) + } + appID := extractIDFromResponse(appResp, "ApplicationId", "applicationId") + result.AppID = appID + ui.Successf("Application: %s", ui.ID(appID)) + + // Step 4: Search and order a number + phoneNumber, err := searchAndOrderNumber(client, acctID) + if err != nil { + result.Status = "complete_no_number" + ui.Warnf("%v", err) + } else { + result.PhoneNumber = phoneNumber + result.Status = "complete" + ui.Successf("Number: %s", ui.ID(phoneNumber)) + } + + fmt.Fprintln(os.Stderr, "") + ui.Headerf("Next steps") + fmt.Fprintf(os.Stderr, " 1. Start your callback server at %s\n", qsCallbackURL) + if result.PhoneNumber != "" { + fmt.Fprintf(os.Stderr, " 2. band call create --from %s --to --app-id %s --answer-url %s\n", + result.PhoneNumber, appID, qsCallbackURL) + } + + return printResult(result) +} + +func searchAndOrderNumber(client *api.Client, acctID string) (string, error) { + searchSpin := ui.NewSpinner(fmt.Sprintf("Searching for number in area code %s...", qsAreaCode)) + searchSpin.Start() + var searchResp interface{} + searchErr := client.Get(fmt.Sprintf("/accounts/%s/availableNumbers?areaCode=%s&quantity=1", acctID, qsAreaCode), &searchResp) + searchSpin.Stop() + if searchErr != nil { + return "", fmt.Errorf("number search failed: %w", searchErr) + } + + phoneNumber := extractPhoneNumber(searchResp) + if phoneNumber == "" { + return "", fmt.Errorf("no numbers available in area code %s", qsAreaCode) + } + + orderSpin := ui.NewSpinner(fmt.Sprintf("Ordering %s...", phoneNumber)) + orderSpin.Start() + var orderResp interface{} + orderBody := api.XMLBody{ + RootElement: "Order", + Data: map[string]interface{}{ + "ExistingTelephoneNumberOrderType": map[string]interface{}{ + "TelephoneNumberList": map[string]interface{}{ + "TelephoneNumber": phoneNumber, + }, + }, + "SiteId": acctID, // orders need a site ID; for VCP path this may need adjustment + }, + } + orderErr := client.Post(fmt.Sprintf("/accounts/%s/orders", acctID), orderBody, &orderResp) + orderSpin.Stop() + if orderErr != nil { + return "", fmt.Errorf("number order failed: %w", orderErr) + } + + return phoneNumber, nil +} + +func printResult(r quickstartResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) +} + +// extractIDFromResponse walks a response (possibly nested from XML) to find an ID field. +func extractIDFromResponse(resp interface{}, keys ...string) string { + data, err := json.Marshal(resp) + if err != nil { + return "" + } + var flat map[string]interface{} + if err := json.Unmarshal(data, &flat); err != nil { + return "" + } + // Try top level + for _, k := range keys { + if v := findInMap(flat, k); v != "" { + return v + } + } + // Try one level deep (common with data wrapper) + if d, ok := flat["data"].(map[string]interface{}); ok { + for _, k := range keys { + if v := findInMap(d, k); v != "" { + return v + } + } + } + return "" +} + +func findInMap(m map[string]interface{}, key string) string { + for k, v := range m { + if k == key { + switch val := v.(type) { + case string: + if val != "" { + return val + } + case float64: + return fmt.Sprintf("%.0f", val) + } + } + // Recurse into nested maps + if nested, ok := v.(map[string]interface{}); ok { + if found := findInMap(nested, key); found != "" { + return found + } + } + } + return "" +} + +func extractPhoneNumber(resp interface{}) string { + data, err := json.Marshal(resp) + if err != nil { + return "" + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + var arr []interface{} + if err := json.Unmarshal(data, &arr); err == nil && len(arr) > 0 { + if s, ok := arr[0].(string); ok { + return s + } + } + return "" + } + + // Walk common shapes + if found := findInMap(raw, "TelephoneNumber"); found != "" { + return found + } + + return "" +} diff --git a/cmd/quickstart/quickstart_test.go b/cmd/quickstart/quickstart_test.go new file mode 100644 index 0000000..0a8d4f0 --- /dev/null +++ b/cmd/quickstart/quickstart_test.go @@ -0,0 +1,182 @@ +package quickstart + +import ( + "testing" +) + +func TestExtractIDFromResponse(t *testing.T) { + tests := []struct { + name string + resp interface{} + keys []string + want string + }{ + { + name: "top-level string ID", + resp: map[string]interface{}{"ApplicationId": "abc-123"}, + keys: []string{"ApplicationId"}, + want: "abc-123", + }, + { + name: "top-level numeric ID", + resp: map[string]interface{}{"Id": float64(12345)}, + keys: []string{"Id"}, + want: "12345", + }, + { + name: "nested in data wrapper", + resp: map[string]interface{}{ + "data": map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-456", + }, + }, + keys: []string{"voiceConfigurationPackageId"}, + want: "vcp-456", + }, + { + name: "deeply nested", + resp: map[string]interface{}{ + "SipPeerResponse": map[string]interface{}{ + "SipPeer": map[string]interface{}{ + "PeerId": "99", + }, + }, + }, + keys: []string{"PeerId"}, + want: "99", + }, + { + name: "first matching key wins", + resp: map[string]interface{}{"ApplicationId": "first", "applicationId": "second"}, + keys: []string{"ApplicationId", "applicationId"}, + want: "first", + }, + { + name: "fallback to second key", + resp: map[string]interface{}{"id": "fallback"}, + keys: []string{"ApplicationId", "id"}, + want: "fallback", + }, + { + name: "no matching key", + resp: map[string]interface{}{"unrelated": "value"}, + keys: []string{"ApplicationId"}, + want: "", + }, + { + name: "nil response", + resp: nil, + keys: []string{"Id"}, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractIDFromResponse(tc.resp, tc.keys...) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestExtractPhoneNumber(t *testing.T) { + tests := []struct { + name string + resp interface{} + want string + }{ + { + name: "nested TelephoneNumber", + resp: map[string]interface{}{ + "SearchResult": map[string]interface{}{ + "TelephoneNumber": "+19195551234", + }, + }, + want: "+19195551234", + }, + { + name: "top-level TelephoneNumber", + resp: map[string]interface{}{"TelephoneNumber": "+19195559876"}, + want: "+19195559876", + }, + { + name: "array response", + resp: []interface{}{"+19195551234", "+19195551235"}, + want: "+19195551234", + }, + { + name: "no phone number", + resp: map[string]interface{}{"status": "ok"}, + want: "", + }, + { + name: "nil response", + resp: nil, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractPhoneNumber(tc.resp) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestFindInMap(t *testing.T) { + tests := []struct { + name string + m map[string]interface{} + key string + want string + }{ + { + name: "flat string", + m: map[string]interface{}{"name": "hello"}, + key: "name", + want: "hello", + }, + { + name: "flat numeric", + m: map[string]interface{}{"count": float64(42)}, + key: "count", + want: "42", + }, + { + name: "nested", + m: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner": "found", + }, + }, + key: "inner", + want: "found", + }, + { + name: "empty string value", + m: map[string]interface{}{"id": ""}, + key: "id", + want: "", + }, + { + name: "missing key", + m: map[string]interface{}{"other": "value"}, + key: "id", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := findInMap(tc.m, tc.key) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/recording/delete.go b/cmd/recording/delete.go new file mode 100644 index 0000000..28f4916 --- /dev/null +++ b/cmd/recording/delete.go @@ -0,0 +1,41 @@ +package recording + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { + Cmd.AddCommand(deleteCmd) +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a recording", + Args: cobra.ExactArgs(2), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + if err := cmdutil.ValidateID(args[1]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.Delete(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil); err != nil { + return fmt.Errorf("deleting recording: %w", err) + } + + fmt.Printf("Recording %s deleted.\n", args[1]) + return nil +} diff --git a/cmd/recording/download.go b/cmd/recording/download.go new file mode 100644 index 0000000..9680c5c --- /dev/null +++ b/cmd/recording/download.go @@ -0,0 +1,51 @@ +package recording + +import ( + "fmt" + "net/url" + "os" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var downloadOutput string + +func init() { + downloadCmd.Flags().StringVar(&downloadOutput, "output", "", "File path to write the recording to (required)") + _ = downloadCmd.MarkFlagRequired("output") + Cmd.AddCommand(downloadCmd) +} + +var downloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download a recording to a file", + Args: cobra.ExactArgs(2), + RunE: runDownload, +} + +func runDownload(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + if err := cmdutil.ValidateID(args[1]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + data, err := client.GetRaw(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/media", acctID, url.PathEscape(args[0]), url.PathEscape(args[1]))) + if err != nil { + return fmt.Errorf("downloading recording: %w", err) + } + + if err := os.WriteFile(downloadOutput, data, 0644); err != nil { + return fmt.Errorf("writing recording to file: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Recording saved to %s\n", downloadOutput) + return nil +} diff --git a/cmd/recording/get.go b/cmd/recording/get.go new file mode 100644 index 0000000..21e6200 --- /dev/null +++ b/cmd/recording/get.go @@ -0,0 +1,43 @@ +package recording + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get metadata for a specific recording", + Args: cobra.ExactArgs(2), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + if err := cmdutil.ValidateID(args[1]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { + return fmt.Errorf("getting recording: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/recording/list.go b/cmd/recording/list.go new file mode 100644 index 0000000..f7c1462 --- /dev/null +++ b/cmd/recording/list.go @@ -0,0 +1,40 @@ +package recording + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list ", + Short: "List recordings for a call", + Args: cobra.ExactArgs(1), + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings", acctID, url.PathEscape(args[0])), &result); err != nil { + return fmt.Errorf("listing recordings: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/recording/recording.go b/cmd/recording/recording.go new file mode 100644 index 0000000..466cee7 --- /dev/null +++ b/cmd/recording/recording.go @@ -0,0 +1,9 @@ +package recording + +import "github.com/spf13/cobra" + +// Cmd is the `band recording` parent command. +var Cmd = &cobra.Command{ + Use: "recording", + Short: "Manage call recordings", +} diff --git a/cmd/recording/recording_test.go b/cmd/recording/recording_test.go new file mode 100644 index 0000000..911086d --- /dev/null +++ b/cmd/recording/recording_test.go @@ -0,0 +1,40 @@ +package recording + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "recording" { + t.Errorf("Use = %q, want %q", Cmd.Use, "recording") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"get ", "list ", "delete ", "download "} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestGetArgs(t *testing.T) { + if getCmd.Args == nil { + t.Fatal("get command should have arg validation") + } +} + +func TestDeleteArgs(t *testing.T) { + if deleteCmd.Args == nil { + t.Fatal("delete command should have arg validation") + } +} + +func TestDownloadRequiredFlags(t *testing.T) { + f := downloadCmd.Flags().Lookup("output") + if f == nil { + t.Error("missing flag \"output\"") + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..57f0b03 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/config" + "github.com/Bandwidth/cli/internal/ui" + versionpkg "github.com/Bandwidth/cli/internal/version" + + accountcmd "github.com/Bandwidth/cli/cmd/account" + appcmd "github.com/Bandwidth/cli/cmd/app" + authcmd "github.com/Bandwidth/cli/cmd/auth" + bxmlcmd "github.com/Bandwidth/cli/cmd/bxml" + callcmd "github.com/Bandwidth/cli/cmd/call" + locationcmd "github.com/Bandwidth/cli/cmd/location" + messagecmd "github.com/Bandwidth/cli/cmd/message" + numbercmd "github.com/Bandwidth/cli/cmd/number" + quickstartcmd "github.com/Bandwidth/cli/cmd/quickstart" + recordingcmd "github.com/Bandwidth/cli/cmd/recording" + shortcodecmd "github.com/Bandwidth/cli/cmd/shortcode" + sitecmd "github.com/Bandwidth/cli/cmd/site" + tendlccmd "github.com/Bandwidth/cli/cmd/tendlc" + tfvcmd "github.com/Bandwidth/cli/cmd/tfv" + tnoptioncmd "github.com/Bandwidth/cli/cmd/tnoption" + transcriptioncmd "github.com/Bandwidth/cli/cmd/transcription" + vcpcmd "github.com/Bandwidth/cli/cmd/vcp" +) + +var ( + format string + plain bool + accountID string + environment string + + // version is set by goreleaser via ldflags at build time. + version = "dev" +) + +// updateResult receives the version check result from the background goroutine. +var updateResult chan *versionpkg.CheckResult + +var rootCmd = &cobra.Command{ + Use: "band", + Short: "Bandwidth CLI — manage voice, messaging, numbers, and more from the command line", + Long: "The official Bandwidth CLI. Build and debug voice applications, send messages, manage phone numbers, and control calls.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Kick off version check in background so it doesn't slow down the command. + updateResult = make(chan *versionpkg.CheckResult, 1) + go func() { + updateResult <- versionpkg.Check(version) + }() + if !term.IsTerminal(int(os.Stdout.Fd())) { + // Auto-enable plain mode for non-terminal output (scripts, pipes) + // unless the user explicitly chose a different format. + if !cmd.Root().Flag("plain").Changed && !cmd.Root().Flag("format").Changed { + plain = true + } + } + + // Show active account when ambiguous (multiple accounts or system-wide scope). + // Skip for auth/account/bxml/version commands that don't need an account. + showAccountHint(cmd) + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if updateResult == nil { + return + } + result := <-updateResult + if result != nil { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, ui.Warn(result.NoticeMessage())) + } + }, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&format, "format", "json", "Output format: json or table") + rootCmd.PersistentFlags().BoolVar(&plain, "plain", false, "Simplified flat JSON output (recommended for scripts and agents)") + rootCmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Bandwidth account ID (overrides config)") + rootCmd.PersistentFlags().StringVar(&environment, "environment", "", "API environment: prod, test (overrides config)") + rootCmd.AddCommand(authcmd.Cmd) + rootCmd.AddCommand(accountcmd.Cmd) + rootCmd.AddCommand(sitecmd.Cmd) + rootCmd.AddCommand(locationcmd.Cmd) + rootCmd.AddCommand(appcmd.Cmd) + rootCmd.AddCommand(numbercmd.Cmd) + rootCmd.AddCommand(callcmd.Cmd) + rootCmd.AddCommand(messagecmd.Cmd) + rootCmd.AddCommand(recordingcmd.Cmd) + rootCmd.AddCommand(transcriptioncmd.Cmd) + rootCmd.AddCommand(bxmlcmd.Cmd) + rootCmd.AddCommand(quickstartcmd.Cmd) + rootCmd.AddCommand(vcpcmd.Cmd) + rootCmd.AddCommand(tendlccmd.Cmd) + rootCmd.AddCommand(shortcodecmd.Cmd) + rootCmd.AddCommand(tfvcmd.Cmd) + rootCmd.AddCommand(tnoptioncmd.Cmd) + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print CLI version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("band version %s\n", version) + }, +} + +func Execute() error { + api.Version = version + return rootCmd.Execute() +} + +func GetFormat() string { + if f := os.Getenv("BW_FORMAT"); f != "" && format == "json" { + return f + } + return format +} + +func GetAccountID() string { + if accountID != "" { + return accountID + } + return os.Getenv("BW_ACCOUNT_ID") +} + +// GetPlain returns true if the --plain flag was set. +func GetPlain() bool { + return plain +} + +// showAccountHint prints the active account to stderr when there's ambiguity +// (multiple accounts or system-wide scope). Skips commands that don't need accounts. +func showAccountHint(cmd *cobra.Command) { + // Skip for commands that don't make API calls requiring an account + root := cmd.Root() + if cmd == root { + return + } + name := cmd.Name() + parent := "" + if cmd.Parent() != nil && cmd.Parent() != root { + parent = cmd.Parent().Name() + } + // Skip auth, account (registration), bxml, version, help, completion + skipParents := map[string]bool{"auth": true, "account": true, "bxml": true} + skipNames := map[string]bool{"version": true, "help": true, "completion": true} + if skipParents[parent] || skipParents[name] || skipNames[name] { + return + } + + cfgPath, err := config.DefaultPath() + if err != nil { + return + } + cfg, err := config.Load(cfgPath) + if err != nil { + return + } + p := cfg.ActiveProfileConfig() + + // Only show when ambiguous + hasMultipleAccounts := len(p.Accounts) > 1 + hasSystemScope := len(p.Accounts) == 0 && p.ClientID != "" + hasMultipleProfiles := len(cfg.Profiles) > 1 + + if !hasMultipleAccounts && !hasSystemScope && !hasMultipleProfiles { + return + } + + // Check if --account-id was explicitly passed + explicitAccount := cmd.Root().Flag("account-id").Value.String() + + profileName := cfg.ActiveProfile + if profileName == "" { + profileName = "default" + } + + acctID := explicitAccount + if acctID == "" { + acctID = p.AccountID + } + if acctID == "" { + acctID = "(none)" + } + + parts := []string{fmt.Sprintf("account: %s", ui.ID(acctID))} + if hasMultipleProfiles { + parts = append(parts, fmt.Sprintf("profile: %s", profileName)) + } + + // Show environment when the user operates across multiple environments + // or is on a non-default one. Customers with only prod don't need the noise. + env := p.Environment + if env == "" { + env = "prod" + } + if env != "prod" || cfg.HasMultipleEnvironments() { + parts = append(parts, fmt.Sprintf("env: %s", env)) + } + + fmt.Fprintf(os.Stderr, "%s\n", ui.Muted("["+strings.Join(parts, " | ")+"]")) +} diff --git a/cmd/shortcode/get.go b/cmd/shortcode/get.go new file mode 100644 index 0000000..d786258 --- /dev/null +++ b/cmd/shortcode/get.go @@ -0,0 +1,66 @@ +package shortcode + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var getCountry string + +func init() { + getCmd.Flags().StringVar(&getCountry, "country", "USA", "Country code: USA or CAN") + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get short code details and carrier status", + Long: "Shows details for a specific short code including per-carrier activation status, lease info, and sub-account/location assignment.", + Example: ` band shortcode get 12345 + band shortcode get 12345 --country CAN + band shortcode get 12345 --plain`, + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/shortcodes/%s/%s", + acctID, url.PathEscape(args[0]), url.PathEscape(getCountry)) + + var result interface{} + if err := client.Get(path, &result); err != nil { + if apiErr, ok := err.(*api.APIError); ok { + switch apiErr.StatusCode { + case 403: + return fmt.Errorf("access denied — your credentials may not have short code access.\n"+ + "Contact your Bandwidth account manager to verify") + case 404: + return fmt.Errorf("short code %s not found for country %s on this account", args[0], getCountry) + } + } + return fmt.Errorf("getting short code: %w", err) + } + + // The get endpoint wraps in data array — unwrap to single object + if data := extractData(result); data != nil { + if arr, ok := data.([]interface{}); ok && len(arr) == 1 { + result = arr[0] + } else { + result = data + } + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/shortcode/list.go b/cmd/shortcode/list.go new file mode 100644 index 0000000..aec89d5 --- /dev/null +++ b/cmd/shortcode/list.go @@ -0,0 +1,68 @@ +package shortcode + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + listLimit int + listOffset int +) + +func init() { + listCmd.Flags().IntVar(&listLimit, "limit", 50, "Page size (max 250)") + listCmd.Flags().IntVar(&listOffset, "offset", 0, "Pagination offset") + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List short codes on this account", + Long: "Lists all short codes registered to the account with their status and carrier activation details.", + Example: ` band shortcode list + band shortcode list --plain`, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/shortcodes?limit=%d&offset=%d", + acctID, listLimit, listOffset) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return shortcodeError(err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, extractData(result)) +} + +func shortcodeError(err error) error { + if apiErr, ok := err.(*api.APIError); ok && apiErr.StatusCode == 403 { + return fmt.Errorf("access denied — your credentials may not have short code access.\n"+ + "Contact your Bandwidth account manager to verify") + } + return fmt.Errorf("listing short codes: %w", err) +} + +func extractData(result interface{}) interface{} { + m, ok := result.(map[string]interface{}) + if !ok { + return result + } + if data, exists := m["data"]; exists { + return data + } + return result +} diff --git a/cmd/shortcode/shortcode.go b/cmd/shortcode/shortcode.go new file mode 100644 index 0000000..586d962 --- /dev/null +++ b/cmd/shortcode/shortcode.go @@ -0,0 +1,14 @@ +package shortcode + +import "github.com/spf13/cobra" + +// Cmd is the `band shortcode` parent command. +var Cmd = &cobra.Command{ + Use: "shortcode", + Short: "View short code registrations and carrier status", + Long: `View short codes registered to your account and their per-carrier activation status. + +Short codes are provisioned through carrier agreements outside the API. +These commands are read-only — use them to verify a short code is active +before sending messages through it.`, +} diff --git a/cmd/shortcode/shortcode_test.go b/cmd/shortcode/shortcode_test.go new file mode 100644 index 0000000..1bcae97 --- /dev/null +++ b/cmd/shortcode/shortcode_test.go @@ -0,0 +1,78 @@ +package shortcode + +import ( + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func TestShortcodeError_403(t *testing.T) { + err := shortcodeError(&api.APIError{StatusCode: 403, Body: "Forbidden"}) + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "short code access") { + t.Errorf("got %q, want it to mention short code access", got) + } +} + +func TestShortcodeError_Other(t *testing.T) { + err := shortcodeError(&api.APIError{StatusCode: 500, Body: "Internal Server Error"}) + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "listing short codes") { + t.Errorf("got %q, want it to contain 'listing short codes'", got) + } +} + +func TestExtractData(t *testing.T) { + t.Run("standard response", func(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"shortCode": "12345", "status": "ACTIVE"}, + }, + "page": map[string]interface{}{"totalElements": float64(1)}, + } + data := extractData(resp) + arr, ok := data.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", data) + } + if len(arr) != 1 { + t.Fatalf("expected 1 element, got %d", len(arr)) + } + }) + + t.Run("empty data", func(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{}, + } + data := extractData(resp) + arr, ok := data.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", data) + } + if len(arr) != 0 { + t.Errorf("expected 0 elements, got %d", len(arr)) + } + }) + + t.Run("non-map passthrough", func(t *testing.T) { + data := extractData("not a map") + if data != "not a map" { + t.Error("expected passthrough") + } + }) +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/site/create.go b/cmd/site/create.go new file mode 100644 index 0000000..d9166bc --- /dev/null +++ b/cmd/site/create.go @@ -0,0 +1,65 @@ +package site + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createName string + createDescription string + createIfNotExists bool +) + +func init() { + createCmd.Flags().StringVar(&createName, "name", "", "Sub-account name (required)") + createCmd.Flags().StringVar(&createDescription, "description", "", "Sub-account description") + createCmd.Flags().BoolVar(&createIfNotExists, "if-not-exists", false, "Return existing sub-account if one with the same name already exists") + _ = createCmd.MarkFlagRequired("name") + Cmd.AddCommand(createCmd) +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new sub-account", + RunE: runCreate, +} + +func runCreate(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + + if createIfNotExists { + var listResult interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &listResult); err != nil { + return fmt.Errorf("listing sub-accounts: %w", err) + } + if existing := output.FindByName(listResult, "Name", createName); existing != nil { + return output.StdoutAuto(format, plain, existing) + } + } + + bodyData := map[string]interface{}{ + "Name": createName, + } + if createDescription != "" { + bodyData["Description"] = createDescription + } + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/sites", acctID), api.XMLBody{RootElement: "Site", Data: bodyData}, &result); err != nil { + return fmt.Errorf("creating sub-account: %w", err) + } + + return output.StdoutAuto(format, plain, result) +} + diff --git a/cmd/site/delete.go b/cmd/site/delete.go new file mode 100644 index 0000000..c025381 --- /dev/null +++ b/cmd/site/delete.go @@ -0,0 +1,38 @@ +package site + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { + Cmd.AddCommand(deleteCmd) +} + +var deleteCmd = &cobra.Command{ + Use: "delete [id]", + Short: "Delete a sub-account by ID", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.Delete(fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + return fmt.Errorf("deleting sub-account: %w", err) + } + + fmt.Printf("Sub-account %s deleted.\n", args[0]) + return nil +} diff --git a/cmd/site/get.go b/cmd/site/get.go new file mode 100644 index 0000000..fd4f6d9 --- /dev/null +++ b/cmd/site/get.go @@ -0,0 +1,40 @@ +package site + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get [id]", + Short: "Get a sub-account by ID", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/sites/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + return fmt.Errorf("getting sub-account: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/site/list.go b/cmd/site/list.go new file mode 100644 index 0000000..adece37 --- /dev/null +++ b/cmd/site/list.go @@ -0,0 +1,35 @@ +package site + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all sub-accounts", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/sites", acctID), &result); err != nil { + return fmt.Errorf("listing sub-accounts: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/site/site.go b/cmd/site/site.go new file mode 100644 index 0000000..76f9cb7 --- /dev/null +++ b/cmd/site/site.go @@ -0,0 +1,11 @@ +package site + +import "github.com/spf13/cobra" + +// Cmd is the `band site` parent command. +var Cmd = &cobra.Command{ + Use: "subaccount", + Aliases: []string{"site"}, + Short: "Manage sub-accounts", + Long: "Sub-accounts (formerly called sites) are the top-level organizational unit in Bandwidth's legacy account hierarchy. For Universal Platform accounts, use 'band vcp' instead.", +} diff --git a/cmd/site/site_test.go b/cmd/site/site_test.go new file mode 100644 index 0000000..56054ed --- /dev/null +++ b/cmd/site/site_test.go @@ -0,0 +1,41 @@ +package site + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "subaccount" { + t.Errorf("Use = %q, want %q", Cmd.Use, "subaccount") + } + + if len(Cmd.Aliases) == 0 || Cmd.Aliases[0] != "site" { + t.Error("expected \"site\" alias") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"create", "delete [id]", "get [id]", "list"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestCreateRequiredFlags(t *testing.T) { + f := createCmd.Flags().Lookup("name") + if f == nil { + t.Error("missing flag \"name\"") + } +} + +func TestCreateOptionalFlags(t *testing.T) { + for _, flag := range []string{"description", "if-not-exists"} { + f := createCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + } + } +} diff --git a/cmd/tendlc/campaign_numbers.go b/cmd/tendlc/campaign_numbers.go new file mode 100644 index 0000000..767f5c8 --- /dev/null +++ b/cmd/tendlc/campaign_numbers.go @@ -0,0 +1,53 @@ +package tendlc + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + campaignNumbersLimit int + campaignNumbersOffset int +) + +func init() { + campaignNumbersCmd.Flags().IntVar(&campaignNumbersLimit, "limit", 50, "Page size (max 250)") + campaignNumbersCmd.Flags().IntVar(&campaignNumbersOffset, "offset", 0, "Pagination offset") + campaignsCmd.AddCommand(campaignNumbersCmd) +} + +var campaignNumbersCmd = &cobra.Command{ + Use: "numbers ", + Short: "List phone numbers assigned to a campaign", + Long: "Shows all phone numbers associated with a specific 10DLC campaign, including numbers with provisioning errors.", + Example: ` band tendlc campaigns numbers CR8HFN0`, + Args: cobra.ExactArgs(1), + RunE: runCampaignNumbers, +} + +func runCampaignNumbers(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns/%s/phoneNumbers?limit=%d&offset=%d", + acctID, url.PathEscape(args[0]), campaignNumbersLimit, campaignNumbersOffset) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return roleGateError(err, "Campaign Management") + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, extractData(result)) +} diff --git a/cmd/tendlc/campaigns.go b/cmd/tendlc/campaigns.go new file mode 100644 index 0000000..d7074d4 --- /dev/null +++ b/cmd/tendlc/campaigns.go @@ -0,0 +1,51 @@ +package tendlc + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + campaignsLimit int + campaignsOffset int +) + +func init() { + campaignsCmd.Flags().IntVar(&campaignsLimit, "limit", 50, "Page size (max 250)") + campaignsCmd.Flags().IntVar(&campaignsOffset, "offset", 0, "Pagination offset") + Cmd.AddCommand(campaignsCmd) +} + +var campaignsCmd = &cobra.Command{ + Use: "campaigns", + Short: "List 10DLC campaigns on this account", + Long: "Lists all 10DLC campaigns with their registration status, brand, and phone number associations.", + Example: ` # List all campaigns + band tendlc campaigns + + # Paginate through results + band tendlc campaigns --limit 10 --offset 20`, + RunE: runCampaigns, +} + +func runCampaigns(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/campaigns?limit=%d&offset=%d", + acctID, campaignsLimit, campaignsOffset) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return roleGateError(err, "Campaign Management") + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, extractData(result)) +} diff --git a/cmd/tendlc/helpers.go b/cmd/tendlc/helpers.go new file mode 100644 index 0000000..6ade771 --- /dev/null +++ b/cmd/tendlc/helpers.go @@ -0,0 +1,98 @@ +package tendlc + +import ( + "fmt" + "strings" + + "github.com/Bandwidth/cli/internal/api" +) + +// roleGateError wraps a 403 API error with a targeted message based on the +// API response body. The tendlc endpoints return distinct 403 messages: +// - "is not enabled for the Registration Center" — account feature not enabled +// - "import customer is not enabled" — account is a direct (not import) customer +// - "is not enabled on account" — campaign management feature disabled +// - "does not have access rights" — credential lacks the Campaign Management role +func roleGateError(err error, roleName string) error { + apiErr, ok := err.(*api.APIError) + if !ok || apiErr.StatusCode != 403 { + return fmt.Errorf("API request failed: %w", err) + } + + body := apiErr.Body + + switch { + case strings.Contains(body, "not enabled for the Registration Center"): + return fmt.Errorf("your account is not enabled for the Registration Center.\n"+ + "Contact your Bandwidth account manager to enable the Registration Center feature") + + case strings.Contains(body, "import customer is not enabled"): + return fmt.Errorf("these commands are for customers who register campaigns through TCR and import\n"+ + "them to Bandwidth. Direct campaign registration through the CLI is coming mid-2026.\n"+ + "In the meantime, use the Bandwidth App or the existing Campaign Management API") + + case strings.Contains(body, "direct customer is not enabled"): + return fmt.Errorf("these commands are for customers who register campaigns directly through Bandwidth.\n"+ + "Your account is set up as an import customer (campaigns registered through TCR).\n"+ + "Use the import-specific endpoints or contact your Bandwidth account manager") + + case strings.Contains(body, "is not enabled on account"): + return fmt.Errorf("10DLC campaign management is not enabled on this account.\n"+ + "Contact your Bandwidth account manager to enable messaging and campaign management") + + case strings.Contains(body, "does not have access rights"): + return fmt.Errorf("your credentials don't have the %s role.\n"+ + "Contact your Bandwidth account manager to assign the role to your API user", roleName) + + default: + return fmt.Errorf("access denied (403): %s\n"+ + "Contact your Bandwidth account manager to check your account configuration", body) + } +} + +// extractData unwraps a paginated response to return just the "data" array. +// If the response doesn't match the expected shape, it's returned as-is. +func extractData(result interface{}) interface{} { + m, ok := result.(map[string]interface{}) + if !ok { + return result + } + if data, exists := m["data"]; exists { + return data + } + return result +} + +// filterNumbers applies client-side filtering on the phone numbers list. +// The phoneNumbers endpoint doesn't support server-side filtering on status +// or campaignId, so we filter after fetching. +func filterNumbers(data interface{}, status, campaignID string) interface{} { + arr, ok := data.([]interface{}) + if !ok { + return data + } + var filtered []interface{} + for _, item := range arr { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + if status != "" { + s, _ := m["status"].(string) + if !strings.EqualFold(s, status) { + continue + } + } + if campaignID != "" { + c, _ := m["campaignId"].(string) + if !strings.EqualFold(c, campaignID) { + continue + } + } + filtered = append(filtered, item) + } + if filtered == nil { + return []interface{}{} + } + return filtered +} diff --git a/cmd/tendlc/numbers.go b/cmd/tendlc/numbers.go new file mode 100644 index 0000000..a458eea --- /dev/null +++ b/cmd/tendlc/numbers.go @@ -0,0 +1,96 @@ +package tendlc + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + numbersLimit int + numbersOffset int + numbersCampaignID string + numbersStatus string +) + +func init() { + numbersCmd.Flags().IntVar(&numbersLimit, "limit", 50, "Page size (max 250)") + numbersCmd.Flags().IntVar(&numbersOffset, "offset", 0, "Pagination offset") + numbersCmd.Flags().StringVar(&numbersCampaignID, "campaign-id", "", "Filter by campaign ID") + numbersCmd.Flags().StringVar(&numbersStatus, "status", "", "Filter by status: PROCESSING, SUCCESS, FAILURE") + Cmd.AddCommand(numbersCmd) + Cmd.AddCommand(numberGetCmd) +} + +var numbersCmd = &cobra.Command{ + Use: "numbers", + Short: "List 10DLC registered phone numbers", + Long: "Lists all phone numbers registered for A2P 10DLC traffic, with their campaign assignment and registration status.", + Example: ` # List all registered numbers + band tendlc numbers + + # Filter by campaign + band tendlc numbers --campaign-id CR8HFN0 + + # Filter by status + band tendlc numbers --status SUCCESS`, + RunE: runNumbers, +} + +func runNumbers(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/phoneNumbers?limit=%d&offset=%d", + acctID, numbersLimit, numbersOffset) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return roleGateError(err, "Campaign Management") + } + + data := extractData(result) + + // Client-side filtering — the phoneNumbers endpoint doesn't support + // server-side filtering on status or campaignId. + if numbersStatus != "" || numbersCampaignID != "" { + data = filterNumbers(data, numbersStatus, numbersCampaignID) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, data) +} + +var numberGetCmd = &cobra.Command{ + Use: "number ", + Short: "Get 10DLC registration details for a phone number", + Long: "Shows the 10DLC registration status, campaign assignment, and brand for a specific phone number.", + Example: ` band tendlc number +19195551234`, + Args: cobra.ExactArgs(1), + RunE: runNumberGet, +} + +func runNumberGet(cmd *cobra.Command, args []string) error { + number := cmdutil.NormalizeNumber(args[0]) + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/tendlc/phoneNumbers/%s", acctID, url.PathEscape(number)) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return roleGateError(err, "Campaign Management") + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/tendlc/tendlc.go b/cmd/tendlc/tendlc.go new file mode 100644 index 0000000..b9847b3 --- /dev/null +++ b/cmd/tendlc/tendlc.go @@ -0,0 +1,13 @@ +package tendlc + +import "github.com/spf13/cobra" + +// Cmd is the `band tendlc` parent command. +var Cmd = &cobra.Command{ + Use: "tendlc", + Short: "10DLC campaign and number registration status", + Long: `View 10DLC campaigns, brands, and phone number registration status. + +Requires the Campaign Management role and the Registration Center feature on your +account. If you get a 403 error, contact your Bandwidth account manager to enable access.`, +} diff --git a/cmd/tendlc/tendlc_test.go b/cmd/tendlc/tendlc_test.go new file mode 100644 index 0000000..da7ebf4 --- /dev/null +++ b/cmd/tendlc/tendlc_test.go @@ -0,0 +1,234 @@ +package tendlc + +import ( + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func TestRoleGateError_RegistrationCenter(t *testing.T) { + err := roleGateError(&api.APIError{ + StatusCode: 403, + Body: `{"errors":[{"type":"forbidden","description":"Account 33333 is not enabled for the Registration Center"}]}`, + }, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "not enabled for the Registration Center") { + t.Errorf("got %q, want Registration Center message", got) + } +} + +func TestRoleGateError_ImportCustomer(t *testing.T) { + err := roleGateError(&api.APIError{ + StatusCode: 403, + Body: `{"errors":[{"type":"forbidden","description":"'10DLC campaign management' import customer is not enabled on account 33333"}]}`, + }, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "register campaigns through TCR") { + t.Errorf("got %q, want import customer message", got) + } +} + +func TestRoleGateError_FeatureNotEnabled(t *testing.T) { + err := roleGateError(&api.APIError{ + StatusCode: 403, + Body: `{"errors":[{"type":"forbidden","description":"'10DLC campaign management' is not enabled on account 33333"}]}`, + }, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "campaign management is not enabled") { + t.Errorf("got %q, want feature not enabled message", got) + } +} + +func TestRoleGateError_NoRole(t *testing.T) { + err := roleGateError(&api.APIError{ + StatusCode: 403, + Body: `{"errors":[{"type":"forbidden","description":"client does not have access rights to the content"}]}`, + }, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "Campaign Management role") { + t.Errorf("got %q, want role message", got) + } +} + +func TestRoleGateError_UnknownBody(t *testing.T) { + err := roleGateError(&api.APIError{StatusCode: 403, Body: "something unexpected"}, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "access denied (403)") { + t.Errorf("got %q, want fallback message", got) + } +} + +func TestRoleGateError_OtherStatus(t *testing.T) { + err := roleGateError(&api.APIError{StatusCode: 500, Body: "Internal Server Error"}, "Campaign Management") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); !contains(got, "API request failed") { + t.Errorf("got %q, want API request failed", got) + } +} + +func TestRoleGateError_NonAPIError(t *testing.T) { + err := roleGateError(&api.APIError{StatusCode: 404, Body: "Not Found"}, "TFV") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestExtractData(t *testing.T) { + t.Run("standard paginated response", func(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{ + "phoneNumber": "+12054443942", + "campaignId": "CA3XKE1", + "status": "SUCCESS", + }, + }, + "page": map[string]interface{}{ + "totalElements": float64(1), + }, + } + data := extractData(resp) + arr, ok := data.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", data) + } + if len(arr) != 1 { + t.Fatalf("expected 1 element, got %d", len(arr)) + } + }) + + t.Run("no data key", func(t *testing.T) { + resp := map[string]interface{}{ + "something": "else", + } + data := extractData(resp) + m, ok := data.(map[string]interface{}) + if !ok { + t.Fatalf("expected map, got %T", data) + } + if m["something"] != "else" { + t.Error("expected original response returned as-is") + } + }) + + t.Run("non-map response", func(t *testing.T) { + resp := "just a string" + data := extractData(resp) + if data != resp { + t.Error("expected passthrough for non-map input") + } + }) + + t.Run("nil response", func(t *testing.T) { + data := extractData(nil) + if data != nil { + t.Errorf("expected nil, got %v", data) + } + }) + + t.Run("empty data array", func(t *testing.T) { + resp := map[string]interface{}{ + "data": []interface{}{}, + "page": map[string]interface{}{ + "totalElements": float64(0), + }, + } + data := extractData(resp) + arr, ok := data.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", data) + } + if len(arr) != 0 { + t.Errorf("expected empty array, got %d elements", len(arr)) + } + }) +} + +func TestFilterNumbers(t *testing.T) { + numbers := []interface{}{ + map[string]interface{}{"phoneNumber": "+11111111111", "status": "SUCCESS", "campaignId": "C1"}, + map[string]interface{}{"phoneNumber": "+12222222222", "status": "FAILURE", "campaignId": "C1"}, + map[string]interface{}{"phoneNumber": "+13333333333", "status": "SUCCESS", "campaignId": "C2"}, + map[string]interface{}{"phoneNumber": "+14444444444", "status": "PROCESSING"}, + } + + t.Run("filter by status", func(t *testing.T) { + result := filterNumbers(numbers, "FAILURE", "") + arr := result.([]interface{}) + if len(arr) != 1 { + t.Fatalf("expected 1, got %d", len(arr)) + } + m := arr[0].(map[string]interface{}) + if m["phoneNumber"] != "+12222222222" { + t.Errorf("got %v", m["phoneNumber"]) + } + }) + + t.Run("filter by campaign", func(t *testing.T) { + result := filterNumbers(numbers, "", "C2") + arr := result.([]interface{}) + if len(arr) != 1 { + t.Fatalf("expected 1, got %d", len(arr)) + } + }) + + t.Run("filter by both", func(t *testing.T) { + result := filterNumbers(numbers, "SUCCESS", "C1") + arr := result.([]interface{}) + if len(arr) != 1 { + t.Fatalf("expected 1, got %d", len(arr)) + } + m := arr[0].(map[string]interface{}) + if m["phoneNumber"] != "+11111111111" { + t.Errorf("got %v", m["phoneNumber"]) + } + }) + + t.Run("no matches returns empty array", func(t *testing.T) { + result := filterNumbers(numbers, "FAILURE", "C999") + arr := result.([]interface{}) + if len(arr) != 0 { + t.Errorf("expected 0, got %d", len(arr)) + } + }) + + t.Run("case insensitive", func(t *testing.T) { + result := filterNumbers(numbers, "success", "c1") + arr := result.([]interface{}) + if len(arr) != 1 { + t.Fatalf("expected 1, got %d", len(arr)) + } + }) + + t.Run("non-array passthrough", func(t *testing.T) { + result := filterNumbers("not an array", "SUCCESS", "") + if result != "not an array" { + t.Error("expected passthrough") + } + }) +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstring(s, sub)) +} + +func containsSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/tfv/get.go b/cmd/tfv/get.go new file mode 100644 index 0000000..0fee6c9 --- /dev/null +++ b/cmd/tfv/get.go @@ -0,0 +1,64 @@ +package tfv + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get toll-free verification status", + Long: "Shows the verification status and submission details for a toll-free number.", + Example: ` band tfv get +18005551234 + band tfv get +18005551234 --plain`, + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + number := cmdutil.NormalizeNumber(args[0]) + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v2/accounts/%s/phoneNumbers/%s/tollFreeVerification", + acctID, url.PathEscape(number)) + + var result interface{} + if err := client.Get(path, &result); err != nil { + return tfvError(err, number) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} + +// tfvError wraps API errors with helpful context for common TFV failure modes. +func tfvError(err error, number string) error { + apiErr, ok := err.(*api.APIError) + if !ok { + return fmt.Errorf("checking verification: %w", err) + } + switch apiErr.StatusCode { + case 403: + return fmt.Errorf("access denied — your credentials don't have the TFV role.\n"+ + "Contact your Bandwidth account manager to enable it") + case 404: + return fmt.Errorf("no verification request found for %s — submit one with: band tfv submit %s", + number, number) + default: + return fmt.Errorf("checking verification: %w", err) + } +} diff --git a/cmd/tfv/submit.go b/cmd/tfv/submit.go new file mode 100644 index 0000000..ccd32b3 --- /dev/null +++ b/cmd/tfv/submit.go @@ -0,0 +1,159 @@ +package tfv + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + submitBusinessName string + submitBusinessAddr string + submitBusinessCity string + submitBusinessState string + submitBusinessZip string + submitContactFirst string + submitContactLast string + submitContactEmail string + submitContactPhone string + submitMessageVolume int + submitUseCase string + submitUseCaseSummary string + submitSampleMessage string + submitPrivacyURL string + submitTermsURL string + submitEntityType string +) + +func init() { + submitCmd.Flags().StringVar(&submitBusinessName, "business-name", "", "Legal business name (required)") + submitCmd.Flags().StringVar(&submitBusinessAddr, "business-addr", "", "Business street address (required)") + submitCmd.Flags().StringVar(&submitBusinessCity, "business-city", "", "Business city (required)") + submitCmd.Flags().StringVar(&submitBusinessState, "business-state", "", "Business state, 2-letter code (required)") + submitCmd.Flags().StringVar(&submitBusinessZip, "business-zip", "", "Business postal code (required)") + submitCmd.Flags().StringVar(&submitContactFirst, "contact-first", "", "Contact first name (required)") + submitCmd.Flags().StringVar(&submitContactLast, "contact-last", "", "Contact last name (required)") + submitCmd.Flags().StringVar(&submitContactEmail, "contact-email", "", "Contact email (required)") + submitCmd.Flags().StringVar(&submitContactPhone, "contact-phone", "", "Contact phone in E.164 (required)") + submitCmd.Flags().IntVar(&submitMessageVolume, "message-volume", 0, "Estimated monthly message volume (required)") + submitCmd.Flags().StringVar(&submitUseCase, "use-case", "", "Use case category, e.g. 2FA, MARKETING (required)") + submitCmd.Flags().StringVar(&submitUseCaseSummary, "use-case-summary", "", "Brief summary of how the number will be used (required)") + submitCmd.Flags().StringVar(&submitSampleMessage, "sample-message", "", "Example message content (required)") + submitCmd.Flags().StringVar(&submitPrivacyURL, "privacy-url", "", "Privacy policy URL (required)") + submitCmd.Flags().StringVar(&submitTermsURL, "terms-url", "", "Terms and conditions URL (required)") + submitCmd.Flags().StringVar(&submitEntityType, "entity-type", "", "Business entity type: SOLE_PROPRIETOR, PRIVATE_PROFIT, PUBLIC_PROFIT, NON_PROFIT, GOVERNMENT (required)") + + _ = submitCmd.MarkFlagRequired("business-name") + _ = submitCmd.MarkFlagRequired("business-addr") + _ = submitCmd.MarkFlagRequired("business-city") + _ = submitCmd.MarkFlagRequired("business-state") + _ = submitCmd.MarkFlagRequired("business-zip") + _ = submitCmd.MarkFlagRequired("contact-first") + _ = submitCmd.MarkFlagRequired("contact-last") + _ = submitCmd.MarkFlagRequired("contact-email") + _ = submitCmd.MarkFlagRequired("contact-phone") + _ = submitCmd.MarkFlagRequired("message-volume") + _ = submitCmd.MarkFlagRequired("use-case") + _ = submitCmd.MarkFlagRequired("use-case-summary") + _ = submitCmd.MarkFlagRequired("sample-message") + _ = submitCmd.MarkFlagRequired("privacy-url") + _ = submitCmd.MarkFlagRequired("terms-url") + _ = submitCmd.MarkFlagRequired("entity-type") + + Cmd.AddCommand(submitCmd) +} + +var submitCmd = &cobra.Command{ + Use: "submit ", + Short: "Submit a toll-free verification request", + Long: `Submits a new toll-free verification request. All fields are required by the +carrier ecosystem. Verification is reviewed by carriers and typically takes +a few business days.`, + Example: ` band tfv submit +18005551234 \ + --business-name "Acme Corp" \ + --business-addr "123 Main St" \ + --business-city "Raleigh" \ + --business-state "NC" \ + --business-zip "27606" \ + --contact-first "Jane" \ + --contact-last "Doe" \ + --contact-email "jane@acme.com" \ + --contact-phone "+19195551234" \ + --message-volume 10000 \ + --use-case "2FA" \ + --use-case-summary "Two-factor authentication codes for user login" \ + --sample-message "Your Acme verification code is 123456" \ + --privacy-url "https://acme.com/privacy" \ + --terms-url "https://acme.com/terms" \ + --entity-type "PRIVATE_PROFIT"`, + Args: cobra.ExactArgs(1), + RunE: runSubmit, +} + +func runSubmit(cmd *cobra.Command, args []string) error { + number := cmdutil.NormalizeNumber(args[0]) + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + body := map[string]interface{}{ + "submission": map[string]interface{}{ + "businessAddress": map[string]interface{}{ + "name": submitBusinessName, + "addr1": submitBusinessAddr, + "city": submitBusinessCity, + "state": submitBusinessState, + "zip": submitBusinessZip, + }, + "businessContact": map[string]interface{}{ + "firstName": submitContactFirst, + "lastName": submitContactLast, + "email": submitContactEmail, + "phoneNumber": submitContactPhone, + }, + "messageVolume": submitMessageVolume, + "useCase": submitUseCase, + "useCaseSummary": submitUseCaseSummary, + "productionMessageContent": submitSampleMessage, + "privacyPolicyUrl": submitPrivacyURL, + "termsAndConditionsUrl": submitTermsURL, + "businessEntityType": submitEntityType, + }, + } + + path := fmt.Sprintf("/api/v2/accounts/%s/phoneNumbers/%s/tollFreeVerification", + acctID, url.PathEscape(number)) + + var result interface{} + if err := client.Post(path, body, &result); err != nil { + if apiErr, ok := err.(*api.APIError); ok { + switch apiErr.StatusCode { + case 403: + return fmt.Errorf("access denied — your credentials don't have the TFV role.\n"+ + "Contact your Bandwidth account manager to enable it") + case 400: + return fmt.Errorf("validation error: %s", apiErr.Body) + } + } + return fmt.Errorf("submitting verification: %w", err) + } + + // POST returns 202 with empty body on success + if result == nil { + result = map[string]interface{}{ + "status": "submitted", + "phoneNumber": number, + "message": "Verification request submitted. Check status with: band tfv get " + number, + } + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/tfv/tfv.go b/cmd/tfv/tfv.go new file mode 100644 index 0000000..b6b7bb3 --- /dev/null +++ b/cmd/tfv/tfv.go @@ -0,0 +1,13 @@ +package tfv + +import "github.com/spf13/cobra" + +// Cmd is the `band tfv` parent command. +var Cmd = &cobra.Command{ + Use: "tfv", + Short: "Toll-free verification management", + Long: `Check and manage toll-free number verification status. + +Requires the TFV role on your account. If you get a 403 error, +contact your Bandwidth account manager to enable the role.`, +} diff --git a/cmd/tfv/tfv_test.go b/cmd/tfv/tfv_test.go new file mode 100644 index 0000000..e68bdab --- /dev/null +++ b/cmd/tfv/tfv_test.go @@ -0,0 +1,64 @@ +package tfv + +import ( + "fmt" + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func TestTfvError_403(t *testing.T) { + err := tfvError(&api.APIError{StatusCode: 403, Body: "Forbidden"}, "+18005551234") + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "TFV role") { + t.Errorf("got %q, want it to mention TFV role", got) + } +} + +func TestTfvError_404(t *testing.T) { + err := tfvError(&api.APIError{StatusCode: 404, Body: "Not Found"}, "+18005551234") + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "band tfv submit") { + t.Errorf("got %q, want it to suggest band tfv submit", got) + } + if !contains(got, "+18005551234") { + t.Errorf("got %q, want it to include the phone number", got) + } +} + +func TestTfvError_OtherStatus(t *testing.T) { + err := tfvError(&api.APIError{StatusCode: 500, Body: "Internal Server Error"}, "+18005551234") + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "checking verification") { + t.Errorf("got %q, want it to contain 'checking verification'", got) + } +} + +func TestTfvError_NonAPIError(t *testing.T) { + err := tfvError(fmt.Errorf("connection refused"), "+18005551234") + if err == nil { + t.Fatal("expected error, got nil") + } + got := err.Error() + if !contains(got, "connection refused") { + t.Errorf("got %q, want it to wrap the original error", got) + } +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/tnoption/assign.go b/cmd/tnoption/assign.go new file mode 100644 index 0000000..01a18e3 --- /dev/null +++ b/cmd/tnoption/assign.go @@ -0,0 +1,157 @@ +package tnoption + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + assignCampaignID string + assignWait bool + assignTimeout time.Duration +) + +func init() { + assignCmd.Flags().StringVar(&assignCampaignID, "campaign-id", "", "10DLC campaign ID to assign (required)") + assignCmd.Flags().BoolVar(&assignWait, "wait", false, "Wait until the order completes") + assignCmd.Flags().DurationVar(&assignTimeout, "timeout", 60*time.Second, "Maximum time to wait (default 60s)") + _ = assignCmd.MarkFlagRequired("campaign-id") + Cmd.AddCommand(assignCmd) +} + +var assignCmd = &cobra.Command{ + Use: "assign [number...]", + Short: "Assign phone numbers to a 10DLC campaign", + Long: `Creates a TN Option Order to assign one or more phone numbers to a 10DLC campaign. + +This is the step that connects your phone numbers to an approved campaign so +messages will deliver. Without this, carriers will reject messages with error 4476.`, + Example: ` band tnoption assign +19195551234 --campaign-id CA3XKE1 + band tnoption assign +19195551234 +19195551235 --campaign-id CA3XKE1 --wait`, + Args: cobra.MinimumNArgs(1), + RunE: runAssign, +} + +func runAssign(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + // TN Options API wants full E.164 (with + prefix). + numbers := make([]string, len(args)) + for i, n := range args { + numbers[i] = cmdutil.NormalizeNumber(n) + } + + body := map[string]interface{}{ + "TnOptionGroups": map[string]interface{}{ + "TnOptionGroup": map[string]interface{}{ + "A2pSettings": map[string]interface{}{ + "Action": "asSpecified", + "CampaignId": assignCampaignID, + "MessageClass": "M", + }, + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": numbers, + }, + }, + }, + } + + var result interface{} + if err := client.Post( + fmt.Sprintf("/accounts/%s/tnoptions", acctID), + api.XMLBody{RootElement: "TnOptionOrder", Data: body}, + &result, + ); err != nil { + return fmt.Errorf("creating TN option order: %w", err) + } + + if !assignWait { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + // Extract order ID from response to poll. + orderID := extractOrderID(result) + if orderID == "" { + // Can't poll without an order ID; just return what we got. + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + final, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: assignTimeout, + Check: func() (bool, interface{}, error) { + var orderResult interface{} + if err := client.Get( + fmt.Sprintf("/accounts/%s/tnoptions/%s", acctID, orderID), + &orderResult, + ); err != nil { + return false, nil, fmt.Errorf("polling TN option order: %w", err) + } + status := extractStatus(orderResult) + switch strings.ToUpper(status) { + case "COMPLETE": + return true, orderResult, nil + case "FAILED", "PARTIAL": + return true, orderResult, nil + default: + return false, nil, nil + } + }, + }) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, final) +} + +// stripE164 converts "+19195551234" to "9195551234" for the Dashboard API. +func stripE164(number string) string { + n := strings.TrimPrefix(number, "+") + if len(n) == 11 && strings.HasPrefix(n, "1") { + return n[1:] + } + return n +} + +// extractOrderID digs the order ID out of the API response. +func extractOrderID(result interface{}) string { + return digString(result, "OrderId") +} + +// extractStatus digs the processing status out of the API response. +func extractStatus(result interface{}) string { + return digString(result, "ProcessingStatus") +} + +// digString recursively searches a map for the first occurrence of a key +// and returns its string value. +func digString(v interface{}, key string) string { + switch val := v.(type) { + case map[string]interface{}: + if s, ok := val[key]; ok { + if str, ok := s.(string); ok { + return str + } + } + for _, child := range val { + if found := digString(child, key); found != "" { + return found + } + } + } + return "" +} diff --git a/cmd/tnoption/get.go b/cmd/tnoption/get.go new file mode 100644 index 0000000..c5e4a81 --- /dev/null +++ b/cmd/tnoption/get.go @@ -0,0 +1,37 @@ +package tnoption + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get the status of a TN Option Order", + Example: ` band tnoption get ddbdc72e-dc27-490c-904e-d0c11291b095`, + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/tnoptions/%s", acctID, args[0]), &result); err != nil { + return fmt.Errorf("getting TN option order: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/tnoption/list.go b/cmd/tnoption/list.go new file mode 100644 index 0000000..42bcec5 --- /dev/null +++ b/cmd/tnoption/list.go @@ -0,0 +1,59 @@ +package tnoption + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + listStatus string + listTN string +) + +func init() { + listCmd.Flags().StringVar(&listStatus, "status", "", "Filter by order status (COMPLETE, FAILED, PARTIAL, PROCESSING)") + listCmd.Flags().StringVar(&listTN, "tn", "", "Filter by phone number") + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List TN Option Orders", + Example: ` band tnoption list + band tnoption list --status COMPLETE + band tnoption list --tn +19195551234`, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + params := url.Values{} + if listStatus != "" { + params.Set("status", listStatus) + } + if listTN != "" { + params.Set("tn", stripE164(listTN)) + } + + path := fmt.Sprintf("/accounts/%s/tnoptions", acctID) + if len(params) > 0 { + path += "?" + params.Encode() + } + + var result interface{} + if err := client.Get(path, &result); err != nil { + return fmt.Errorf("listing TN option orders: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/tnoption/tnoption.go b/cmd/tnoption/tnoption.go new file mode 100644 index 0000000..87abc52 --- /dev/null +++ b/cmd/tnoption/tnoption.go @@ -0,0 +1,17 @@ +package tnoption + +import "github.com/spf13/cobra" + +// Cmd is the `band tnoption` parent command. +var Cmd = &cobra.Command{ + Use: "tnoption", + Short: "Manage TN Option Orders (assign numbers to campaigns, set SMS/CNAM options)", + Long: `Create and query TN Option Orders on the Bandwidth Dashboard API. + +The most common use case is assigning phone numbers to 10DLC campaigns: + + band tnoption assign +19195551234 --campaign-id CA3XKE1 + +TN Option Orders can also enable/disable SMS, set CNAM display, configure +port-out passcodes, and more. Use "band tnoption create" for full control.`, +} diff --git a/cmd/tnoption/tnoption_test.go b/cmd/tnoption/tnoption_test.go new file mode 100644 index 0000000..7241a18 --- /dev/null +++ b/cmd/tnoption/tnoption_test.go @@ -0,0 +1,70 @@ +package tnoption + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "tnoption" { + t.Errorf("Use = %q, want %q", Cmd.Use, "tnoption") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"assign [number...]", "get ", "list"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestAssignRequiredFlags(t *testing.T) { + f := assignCmd.Flags().Lookup("campaign-id") + if f == nil { + t.Fatal("missing --campaign-id flag") + } + ann := f.Annotations + if _, ok := ann["cobra_annotation_bash_completion_one_required_flag"]; !ok { + t.Error("--campaign-id should be required") + } +} + +func TestStripE164(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"+19195551234", "9195551234"}, + {"19195551234", "9195551234"}, + {"9195551234", "9195551234"}, + {"+449195551234", "449195551234"}, + } + for _, tt := range tests { + if got := stripE164(tt.input); got != tt.want { + t.Errorf("stripE164(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDigString(t *testing.T) { + resp := map[string]interface{}{ + "TnOptionOrderResponse": map[string]interface{}{ + "TnOptionOrder": map[string]interface{}{ + "OrderId": "abc-123", + "ProcessingStatus": "COMPLETE", + }, + }, + } + + if got := digString(resp, "OrderId"); got != "abc-123" { + t.Errorf("digString(OrderId) = %q, want %q", got, "abc-123") + } + if got := digString(resp, "ProcessingStatus"); got != "COMPLETE" { + t.Errorf("digString(ProcessingStatus) = %q, want %q", got, "COMPLETE") + } + if got := digString(resp, "Missing"); got != "" { + t.Errorf("digString(Missing) = %q, want empty", got) + } +} diff --git a/cmd/transcription/create.go b/cmd/transcription/create.go new file mode 100644 index 0000000..16dcf14 --- /dev/null +++ b/cmd/transcription/create.go @@ -0,0 +1,88 @@ +package transcription + +import ( + "fmt" + "net/url" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createWait bool + createTimeout time.Duration +) + +func init() { + createCmd.Flags().BoolVar(&createWait, "wait", false, "Wait until the transcription has content") + createCmd.Flags().DurationVar(&createTimeout, "timeout", 60*time.Second, "Maximum time to wait (default 60s)") + Cmd.AddCommand(createCmd) +} + +var createCmd = &cobra.Command{ + Use: "create ", + Short: "Request a transcription for a recording", + Args: cobra.ExactArgs(2), + RunE: runCreate, +} + +func runCreate(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + if err := cmdutil.ValidateID(args[1]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Post(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), nil, &result); err != nil { + return fmt.Errorf("creating transcription: %w", err) + } + + if !createWait { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + callID, recordingID := args[0], args[1] + getPath := fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(callID), url.PathEscape(recordingID)) + + final, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 5 * time.Second, + Timeout: createTimeout, + Check: func() (bool, interface{}, error) { + var t interface{} + if err := client.Get(getPath, &t); err != nil { + return false, nil, fmt.Errorf("polling transcription: %w", err) + } + // Consider done when the result is non-nil and has content. + // The Voice API returns a JSON object with a "transcripts" array. + m, ok := t.(map[string]interface{}) + if !ok { + return false, nil, nil + } + transcripts, ok := m["transcripts"] + if !ok { + return false, nil, nil + } + arr, ok := transcripts.([]interface{}) + if !ok || len(arr) == 0 { + return false, nil, nil + } + return true, t, nil + }, + }) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, final) +} diff --git a/cmd/transcription/get.go b/cmd/transcription/get.go new file mode 100644 index 0000000..312c9a2 --- /dev/null +++ b/cmd/transcription/get.go @@ -0,0 +1,43 @@ +package transcription + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get the transcription for a recording", + Args: cobra.ExactArgs(2), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + if err := cmdutil.ValidateID(args[1]); err != nil { + return err + } + client, acctID, err := cmdutil.VoiceClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/accounts/%s/calls/%s/recordings/%s/transcription", acctID, url.PathEscape(args[0]), url.PathEscape(args[1])), &result); err != nil { + return fmt.Errorf("getting transcription: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/transcription/transcription.go b/cmd/transcription/transcription.go new file mode 100644 index 0000000..e51cb57 --- /dev/null +++ b/cmd/transcription/transcription.go @@ -0,0 +1,9 @@ +package transcription + +import "github.com/spf13/cobra" + +// Cmd is the `band transcription` parent command. +var Cmd = &cobra.Command{ + Use: "transcription", + Short: "Manage call recording transcriptions", +} diff --git a/cmd/transcription/transcription_test.go b/cmd/transcription/transcription_test.go new file mode 100644 index 0000000..ec70909 --- /dev/null +++ b/cmd/transcription/transcription_test.go @@ -0,0 +1,36 @@ +package transcription + +import ( + "testing" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "transcription" { + t.Errorf("Use = %q, want %q", Cmd.Use, "transcription") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"create ", "get "} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestCreateArgs(t *testing.T) { + if createCmd.Args == nil { + t.Fatal("create command should have arg validation") + } +} + +func TestCreateFlags(t *testing.T) { + for _, flag := range []string{"wait", "timeout"} { + f := createCmd.Flags().Lookup(flag) + if f == nil { + t.Errorf("missing flag %q", flag) + } + } +} diff --git a/cmd/vcp/assign.go b/cmd/vcp/assign.go new file mode 100644 index 0000000..bd2a883 --- /dev/null +++ b/cmd/vcp/assign.go @@ -0,0 +1,66 @@ +package vcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(assignCmd) +} + +var assignCmd = &cobra.Command{ + Use: "assign [number...]", + Short: "Assign phone numbers to a VCP", + Long: "Assigns one or more phone numbers to a Voice Configuration Package. Numbers must be in E.164 format and owned by the account.", + Example: ` band vcp assign abc-123-def +19195551234 + band vcp assign abc-123-def +19195551234 +19195551235 +19195551236`, + Args: cobra.MinimumNArgs(2), + RunE: runAssign, +} + +// BuildAssignBody builds the bulk assign request body. +func BuildAssignBody(numbers []string) map[string]interface{} { + return map[string]interface{}{ + "action": "ADD", + "phoneNumbers": numbers, + } +} + +func runAssign(cmd *cobra.Command, args []string) error { + vcpID := args[0] + if err := cmdutil.ValidateID(vcpID); err != nil { + return err + } + + numbers := args[1:] + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + body := BuildAssignBody(numbers) + + var result interface{} + if err := client.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s/phoneNumbers/bulk", acctID, vcpID), body, &result); err != nil { + return fmt.Errorf("assigning numbers to VCP: %w", err) + } + + // The API returns null on success. Print a confirmation message instead. + if result == nil { + plural := "number" + if len(numbers) > 1 { + plural = "numbers" + } + fmt.Fprintf(cmd.OutOrStdout(), "Assigned %d %s to VCP %s.\n", len(numbers), plural, vcpID) + return nil + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/vcp/create.go b/cmd/vcp/create.go new file mode 100644 index 0000000..3188286 --- /dev/null +++ b/cmd/vcp/create.go @@ -0,0 +1,94 @@ +package vcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + createName string + createDescription string + createAppID string + createIfNotExists bool +) + +func init() { + Cmd.AddCommand(createCmd) + createCmd.Flags().StringVar(&createName, "name", "", "VCP name (required)") + createCmd.Flags().StringVar(&createDescription, "description", "", "VCP description") + createCmd.Flags().StringVar(&createAppID, "app-id", "", "Voice application ID to link") + createCmd.Flags().BoolVar(&createIfNotExists, "if-not-exists", false, "Return existing VCP if one with the same name exists") + createCmd.MarkFlagRequired("name") +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a Voice Configuration Package", + Long: "Creates a Voice Configuration Package for the Universal Platform. VCPs define voice routing and settings for groups of phone numbers. Link a voice application with --app-id to enable HTTP voice callbacks.", + Example: ` # Create a basic VCP + band vcp create --name "Production VCP" + + # Create linked to a voice app + band vcp create --name "Voice VCP" --app-id abc-123-def + + # Idempotent create (safe for retries) + band vcp create --name "Voice VCP" --if-not-exists`, + RunE: runCreate, +} + +// VCPCreateOpts holds the parameters for creating a VCP. +type VCPCreateOpts struct { + Name string + Description string + AppID string +} + +// BuildVCPCreateBody builds the JSON request body for creating a VCP. +func BuildVCPCreateBody(opts VCPCreateOpts) map[string]interface{} { + body := map[string]interface{}{ + "name": opts.Name, + } + if opts.Description != "" { + body["description"] = opts.Description + } + if opts.AppID != "" { + body["httpVoiceV2ApplicationId"] = opts.AppID + } + return body +} + +func runCreate(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + + if createIfNotExists { + var listResult interface{} + if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &listResult); err == nil { + if existing := output.FindByName(listResult, "name", createName); existing != nil { + return output.StdoutAuto(format, plain, existing) + } + } + } + + body := BuildVCPCreateBody(VCPCreateOpts{ + Name: createName, + Description: createDescription, + AppID: createAppID, + }) + + var result interface{} + if err := client.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), body, &result); err != nil { + return fmt.Errorf("creating VCP: %w", err) + } + + return output.StdoutAuto(format, plain, result) +} + diff --git a/cmd/vcp/delete.go b/cmd/vcp/delete.go new file mode 100644 index 0000000..fb1cde7 --- /dev/null +++ b/cmd/vcp/delete.go @@ -0,0 +1,39 @@ +package vcp + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { + Cmd.AddCommand(deleteCmd) +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a Voice Configuration Package", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func runDelete(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + if err := client.Delete(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), nil); err != nil { + return fmt.Errorf("deleting VCP: %w", err) + } + + fmt.Printf("VCP %s deleted.\n", args[0]) + return nil +} diff --git a/cmd/vcp/get.go b/cmd/vcp/get.go new file mode 100644 index 0000000..f94d9ff --- /dev/null +++ b/cmd/vcp/get.go @@ -0,0 +1,41 @@ +package vcp + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(getCmd) +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get a Voice Configuration Package", + Args: cobra.ExactArgs(1), + RunE: runGet, +} + +func runGet(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, url.PathEscape(args[0])), &result); err != nil { + return fmt.Errorf("getting VCP: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/vcp/list.go b/cmd/vcp/list.go new file mode 100644 index 0000000..fc557f0 --- /dev/null +++ b/cmd/vcp/list.go @@ -0,0 +1,35 @@ +package vcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List Voice Configuration Packages", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &result); err != nil { + return fmt.Errorf("listing VCPs: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/vcp/numbers.go b/cmd/vcp/numbers.go new file mode 100644 index 0000000..1fb4c17 --- /dev/null +++ b/cmd/vcp/numbers.go @@ -0,0 +1,44 @@ +package vcp + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +func init() { + Cmd.AddCommand(numbersCmd) +} + +var numbersCmd = &cobra.Command{ + Use: "numbers ", + Short: "List phone numbers assigned to a VCP", + Args: cobra.ExactArgs(1), + RunE: runNumbers, +} + +func runNumbers(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateID(args[0]); err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + params := url.Values{} + params.Set("voiceConfigurationPackageId", args[0]) + + var result interface{} + if err := client.Get(fmt.Sprintf("/v2/accounts/%s/phoneNumbers/voice?%s", acctID, params.Encode()), &result); err != nil { + return fmt.Errorf("listing VCP numbers: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutPlainList(format, plain, result) +} diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go new file mode 100644 index 0000000..e3d6562 --- /dev/null +++ b/cmd/vcp/update.go @@ -0,0 +1,101 @@ +package vcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +var ( + updateName string + updateDescription string + updateAppID string +) + +func init() { + Cmd.AddCommand(updateCmd) + updateCmd.Flags().StringVar(&updateName, "name", "", "VCP name") + updateCmd.Flags().StringVar(&updateDescription, "description", "", "VCP description") + updateCmd.Flags().StringVar(&updateAppID, "app-id", "", "Voice application ID to link") +} + +var updateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a Voice Configuration Package", + Long: "Updates an existing Voice Configuration Package. Only the specified fields are changed; omitted fields are left as-is.", + Example: ` # Rename a VCP + band vcp update abc-123 --name "New Name" + + # Link a different voice app + band vcp update abc-123 --app-id def-456 + + # Update multiple fields at once + band vcp update abc-123 --name "Updated" --description "New description" --app-id def-456`, + Args: cobra.ExactArgs(1), + RunE: runUpdate, +} + +// VCPUpdateOpts holds optional update fields. A nil pointer means "don't change". +type VCPUpdateOpts struct { + Name *string + Description *string + AppID *string +} + +// BuildVCPUpdateBody builds the PATCH body from update options. +// Returns an error if no fields are set. +func BuildVCPUpdateBody(opts VCPUpdateOpts) (map[string]interface{}, error) { + body := make(map[string]interface{}) + if opts.Name != nil { + body["name"] = *opts.Name + } + if opts.Description != nil { + body["description"] = *opts.Description + } + if opts.AppID != nil { + body["httpVoiceV2ApplicationId"] = *opts.AppID + } + if len(body) == 0 { + return nil, fmt.Errorf("at least one flag (--name, --description, or --app-id) must be provided") + } + return body, nil +} + +func runUpdate(cmd *cobra.Command, args []string) error { + vcpID := args[0] + if err := cmdutil.ValidateID(vcpID); err != nil { + return err + } + + var opts VCPUpdateOpts + if cmd.Flags().Changed("name") { + opts.Name = &updateName + } + if cmd.Flags().Changed("description") { + opts.Description = &updateDescription + } + if cmd.Flags().Changed("app-id") { + opts.AppID = &updateAppID + } + + body, err := BuildVCPUpdateBody(opts) + if err != nil { + return err + } + + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + var result interface{} + if err := client.Patch(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), body, &result); err != nil { + return fmt.Errorf("updating VCP: %w", err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) +} diff --git a/cmd/vcp/vcp.go b/cmd/vcp/vcp.go new file mode 100644 index 0000000..439747d --- /dev/null +++ b/cmd/vcp/vcp.go @@ -0,0 +1,9 @@ +package vcp + +import "github.com/spf13/cobra" + +// Cmd is the `band vcp` parent command. +var Cmd = &cobra.Command{ + Use: "vcp", + Short: "Manage Voice Configuration Packages (Universal Platform)", +} diff --git a/cmd/vcp/vcp_test.go b/cmd/vcp/vcp_test.go new file mode 100644 index 0000000..5435b49 --- /dev/null +++ b/cmd/vcp/vcp_test.go @@ -0,0 +1,109 @@ +package vcp + +import ( + "testing" +) + +// --- VCP Create --- + +func TestBuildVCPCreateBody_Basic(t *testing.T) { + body := BuildVCPCreateBody(VCPCreateOpts{ + Name: "Production VCP", + }) + if body["name"] != "Production VCP" { + t.Errorf("name = %q, want Production VCP", body["name"]) + } + if _, ok := body["description"]; ok { + t.Error("description should not be set when empty") + } + if _, ok := body["httpVoiceV2ApplicationId"]; ok { + t.Error("httpVoiceV2ApplicationId should not be set when empty") + } +} + +func TestBuildVCPCreateBody_WithAllFields(t *testing.T) { + body := BuildVCPCreateBody(VCPCreateOpts{ + Name: "Voice VCP", + Description: "For voice calls", + AppID: "abc-123-def", + }) + if body["name"] != "Voice VCP" { + t.Errorf("name = %q, want Voice VCP", body["name"]) + } + if body["description"] != "For voice calls" { + t.Errorf("description = %q, want For voice calls", body["description"]) + } + if body["httpVoiceV2ApplicationId"] != "abc-123-def" { + t.Errorf("httpVoiceV2ApplicationId = %q, want abc-123-def", body["httpVoiceV2ApplicationId"]) + } +} + +// --- VCP Update --- + +func TestBuildVCPUpdateBody_SingleField(t *testing.T) { + name := "New Name" + body, err := BuildVCPUpdateBody(VCPUpdateOpts{Name: &name}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body["name"] != "New Name" { + t.Errorf("name = %q, want New Name", body["name"]) + } + if len(body) != 1 { + t.Errorf("expected 1 field, got %d", len(body)) + } +} + +func TestBuildVCPUpdateBody_AllFields(t *testing.T) { + name := "Updated" + desc := "New description" + appID := "def-456" + body, err := BuildVCPUpdateBody(VCPUpdateOpts{ + Name: &name, + Description: &desc, + AppID: &appID, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(body) != 3 { + t.Errorf("expected 3 fields, got %d", len(body)) + } + if body["httpVoiceV2ApplicationId"] != "def-456" { + t.Errorf("httpVoiceV2ApplicationId = %q, want def-456", body["httpVoiceV2ApplicationId"]) + } +} + +func TestBuildVCPUpdateBody_NoFields(t *testing.T) { + _, err := BuildVCPUpdateBody(VCPUpdateOpts{}) + if err == nil { + t.Fatal("expected error for no fields, got nil") + } +} + +// --- VCP Assign --- + +func TestBuildAssignBody(t *testing.T) { + body := BuildAssignBody([]string{"+19195551234", "+19195551235"}) + if body["action"] != "ADD" { + t.Errorf("action = %q, want ADD", body["action"]) + } + numbers, ok := body["phoneNumbers"].([]string) + if !ok { + t.Fatal("phoneNumbers is not []string") + } + if len(numbers) != 2 { + t.Errorf("expected 2 numbers, got %d", len(numbers)) + } + if numbers[0] != "+19195551234" { + t.Errorf("numbers[0] = %q, want +19195551234", numbers[0]) + } +} + +func TestBuildAssignBody_SingleNumber(t *testing.T) { + body := BuildAssignBody([]string{"+19195551234"}) + numbers := body["phoneNumbers"].([]string) + if len(numbers) != 1 { + t.Errorf("expected 1 number, got %d", len(numbers)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..31bdd53 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/Bandwidth/cli + +go 1.26.2 + +require ( + github.com/briandowns/spinner v1.23.2 + github.com/fatih/color v1.19.0 + github.com/olekukonko/tablewriter v0.0.5 + github.com/spf13/cobra v1.8.1 + github.com/zalando/go-keyring v0.2.5 + golang.org/x/term v0.41.0 +) + +require ( + github.com/alessio/shellescape v1.4.1 // indirect + github.com/danieljoos/wincred v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.9.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e858449 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..5c5eb08 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,254 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Bandwidth/cli/internal/auth" +) + +// Version is set at startup from the build-time version injected by GoReleaser. +// Falls back to "dev" for local builds without ldflags. +var Version = "dev" + +func userAgent() string { + return "band-cli/" + Version +} + +// APIError represents a non-2xx HTTP response. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + body := strings.TrimSpace(e.Body) + if body == "" { + if status := http.StatusText(e.StatusCode); status != "" { + body = status + } else { + body = "(empty response body)" + } + } + return fmt.Sprintf("API error %d: %s", e.StatusCode, body) +} + +// Requester is the interface satisfied by Client. Commands accept this so +// tests can substitute a mock without hitting real Bandwidth APIs. +type Requester interface { + Get(path string, result interface{}) error + Post(path string, body, result interface{}) error + Put(path string, body, result interface{}) error + Patch(path string, body, result interface{}) error + Delete(path string, result interface{}) error + GetRaw(path string) ([]byte, error) + PutRaw(path string, data []byte, contentType string) error +} + +// Client is an authenticated HTTP client for Bandwidth APIs. +type Client struct { + BaseURL string + httpClient *http.Client + tm *auth.TokenManager + contentType string // "json" (default) or "xml" + basicUser string // if set, use Basic Auth instead of Bearer + basicPassword string +} + +// NewClient creates a Client that obtains Bearer tokens via the given TokenManager. +// Defaults to JSON mode. +func NewClient(baseURL string, tm *auth.TokenManager) *Client { + return &Client{ + BaseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + tm: tm, + contentType: "json", + } +} + +// NewXMLClient creates a Client configured for XML serialization (Dashboard API). +func NewXMLClient(baseURL string, tm *auth.TokenManager) *Client { + return &Client{ + BaseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + tm: tm, + contentType: "xml", + } +} + +// NewBasicAuthClient creates a Client that uses HTTP Basic Authentication. +func NewBasicAuthClient(baseURL, username, password string) *Client { + return &Client{ + BaseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + contentType: "json", + basicUser: username, + basicPassword: password, + } +} + +// NewClientNoAuth creates a Client without authentication (for Build registration). +func NewClientNoAuth(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + contentType: "json", + } +} + +// newRequest creates an authenticated HTTP request with standard headers. +func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, c.BaseURL+path, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if c.basicUser != "" { + req.SetBasicAuth(c.basicUser, c.basicPassword) + } else if c.tm != nil { + token, err := c.tm.GetToken() + if err != nil { + return nil, fmt.Errorf("obtaining auth token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("User-Agent", userAgent()) + return req, nil +} + +// doRaw executes a request and returns the raw response bytes, checking for non-2xx status. +func (c *Client) doRaw(req *http.Request) ([]byte, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(data)} + } + return data, nil +} + +// do executes an HTTP request and unmarshals the response into result. +// result may be nil (e.g., for 204 No Content responses). +func (c *Client) do(method, path string, reqBody, result interface{}) error { + var bodyReader io.Reader + var contentTypeHeader string + + if reqBody != nil { + if c.contentType == "xml" { + xmlb, ok := reqBody.(XMLBody) + if !ok { + return fmt.Errorf("XML client requires XMLBody for request body, got %T", reqBody) + } + data, err := MapToXML(xmlb.RootElement, xmlb.Data) + if err != nil { + return fmt.Errorf("marshaling XML request body: %w", err) + } + bodyReader = bytes.NewReader(data) + contentTypeHeader = "application/xml" + } else { + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshaling request body: %w", err) + } + bodyReader = bytes.NewReader(data) + contentTypeHeader = "application/json" + } + } + + req, err := c.newRequest(method, path, bodyReader) + if err != nil { + return err + } + if reqBody != nil { + req.Header.Set("Content-Type", contentTypeHeader) + } + + respBody, err := c.doRaw(req) + if err != nil { + return err + } + + if result != nil && len(respBody) > 0 { + if c.contentType == "xml" { + m, err := XMLToMap(respBody) + if err != nil { + return fmt.Errorf("unmarshaling XML response: %w", err) + } + switch r := result.(type) { + case *interface{}: + *r = m + case *map[string]interface{}: + *r = m + default: + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("unmarshaling response: %w", err) + } + } + } else { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("unmarshaling response: %w", err) + } + } + } + + return nil +} + +// Get performs a GET request and unmarshals the response into result. +func (c *Client) Get(path string, result interface{}) error { + return c.do(http.MethodGet, path, nil, result) +} + +// Post performs a POST request with body and unmarshals the response into result. +func (c *Client) Post(path string, body, result interface{}) error { + return c.do(http.MethodPost, path, body, result) +} + +// Put performs a PUT request with body and unmarshals the response into result. +func (c *Client) Put(path string, body, result interface{}) error { + return c.do(http.MethodPut, path, body, result) +} + +// Patch performs a PATCH request with body and unmarshals the response into result. +func (c *Client) Patch(path string, body, result interface{}) error { + return c.do(http.MethodPatch, path, body, result) +} + +// Delete performs a DELETE request and unmarshals the response into result. +func (c *Client) Delete(path string, result interface{}) error { + return c.do(http.MethodDelete, path, nil, result) +} + +// GetRaw performs a GET request and returns the raw response bytes. +// Useful for file downloads like recordings. +func (c *Client) GetRaw(path string) ([]byte, error) { + req, err := c.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + return c.doRaw(req) +} + +// PutRaw performs a PUT request with raw binary data and a custom content type. +// Useful for uploading files like MMS media. +func (c *Client) PutRaw(path string, data []byte, contentType string) error { + req, err := c.newRequest(http.MethodPut, path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", contentType) + _, err = c.doRaw(req) + return err +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..4b22406 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,469 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/auth" +) + + +func TestClient_Get(t *testing.T) { + type response struct { + Name string `json:"name"` + } + + // Token server + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token", + "expires_in": 3600, + "token_type": "bearer", + }) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + t.Error("expected Authorization header to be set") + } + if !strings.HasPrefix(authHeader, "Bearer ") { + t.Errorf("expected Bearer token, got %q", authHeader) + } + if r.Header.Get("User-Agent") != userAgent() { + t.Errorf("User-Agent = %q, want %q", r.Header.Get("User-Agent"), userAgent()) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{Name: "test"}) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + var got response + if err := client.Get("/", &got); err != nil { + t.Fatalf("Get() error: %v", err) + } + if got.Name != "test" { + t.Errorf("Name = %q, want %q", got.Name, "test") + } +} + +func TestClient_Post(t *testing.T) { + type request struct { + Value string `json:"value"` + } + type response struct { + Result string `json:"result"` + } + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decoding request body: %v", err) + } + if req.Value != "hello" { + t.Errorf("Value = %q, want %q", req.Value, "hello") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{Result: "ok"}) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + var got response + if err := client.Post("/", request{Value: "hello"}, &got); err != nil { + t.Fatalf("Post() error: %v", err) + } + if got.Result != "ok" { + t.Errorf("Result = %q, want %q", got.Result, "ok") + } +} + +func TestClient_ErrorResponse(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + var got struct{} + err := client.Get("/missing", &got) + if err == nil { + t.Fatal("expected error for non-2xx response, got nil") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound) + } +} + +func TestClient_Put(t *testing.T) { + type request struct { + Name string `json:"name"` + } + type response struct { + Updated bool `json:"updated"` + } + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{Updated: true}) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + var got response + if err := client.Put("/", request{Name: "test"}, &got); err != nil { + t.Fatalf("Put() error: %v", err) + } + if !got.Updated { + t.Error("expected Updated = true") + } +} + +func TestClient_Delete(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + // nil result is valid for 204 No Content + if err := client.Delete("/", nil); err != nil { + t.Fatalf("Delete() error: %v", err) + } +} + +func TestClient_GetRaw(t *testing.T) { + payload := []byte("raw binary data") + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(payload) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + got, err := client.GetRaw("/") + if err != nil { + t.Fatalf("GetRaw() error: %v", err) + } + if string(got) != string(payload) { + t.Errorf("GetRaw() = %q, want %q", got, payload) + } +} + +func TestClient_NoAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + t.Error("expected no Authorization header for NoAuth client") + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct{}{}) + })) + defer srv.Close() + + client := NewClientNoAuth(srv.URL) + + var got struct{} + if err := client.Get("/", &got); err != nil { + t.Fatalf("Get() error: %v", err) + } +} + +func TestAPIError_Error(t *testing.T) { + e := &APIError{StatusCode: 422, Body: "unprocessable entity"} + msg := e.Error() + if msg == "" { + t.Error("APIError.Error() returned empty string") + } +} + +func TestAPIError_Error_EmptyBodyFallsBackToStatusText(t *testing.T) { + // Some endpoints (e.g. /tns) return a 403 with no body. The raw message + // was "API error 403: " — ending with a colon and nothing useful. Fall + // back to the HTTP status text instead. + e := &APIError{StatusCode: 403, Body: ""} + msg := e.Error() + if !strings.Contains(msg, "Forbidden") { + t.Errorf("expected fallback to status text, got %q", msg) + } + if strings.HasSuffix(msg, ": ") { + t.Errorf("message should not end with an empty colon suffix, got %q", msg) + } +} + +func TestAPIError_Error_WhitespaceBodyFallsBack(t *testing.T) { + // A body that is only whitespace is as useless as an empty one. + e := &APIError{StatusCode: 401, Body: " \n\t"} + msg := e.Error() + if !strings.Contains(msg, "Unauthorized") { + t.Errorf("expected fallback to status text, got %q", msg) + } +} + +func TestAPIError_Error_UnknownStatusCode(t *testing.T) { + // Unknown status codes (no http.StatusText mapping) should still produce + // a readable error rather than a trailing colon. + e := &APIError{StatusCode: 999, Body: ""} + msg := e.Error() + if !strings.Contains(msg, "empty response body") { + t.Errorf("expected empty-body marker, got %q", msg) + } +} + +// ---- XML client tests ---- + +func TestXMLClient_Post(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the client sent XML. + ct := r.Header.Get("Content-Type") + if ct != "application/xml" { + t.Errorf("Content-Type = %q, want application/xml", ct) + } + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + // Verify body contains expected XML. + body := make([]byte, 512) + n, _ := r.Body.Read(body) + bodyStr := string(body[:n]) + if !strings.Contains(bodyStr, "") { + t.Errorf("expected XML root element , got:\n%s", bodyStr) + } + if !strings.Contains(bodyStr, "Test Location") { + t.Errorf("expected PeerName element, got:\n%s", bodyStr) + } + + // Respond with XML. + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(` + + + Test Location + 12345 + +`)) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewXMLClient(srv.URL, tm) + + body := XMLBody{ + RootElement: "SipPeer", + Data: map[string]interface{}{ + "PeerName": "Test Location", + }, + } + + var result interface{} + if err := client.Post("/sippeers", body, &result); err != nil { + t.Fatalf("Post() error: %v", err) + } + + // Result should be a map decoded from XML. + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if _, ok := m["SipPeerResponse"]; !ok { + t.Errorf("expected SipPeerResponse key in result, got: %v", m) + } +} + +func TestXMLClient_Get(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(` + + 99 + My Site +`)) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewXMLClient(srv.URL, tm) + + var result interface{} + if err := client.Get("/sites/99", &result); err != nil { + t.Fatalf("Get() error: %v", err) + } + + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if _, ok := m["Site"]; !ok { + t.Errorf("expected Site key in result, got: %v", m) + } +} + +func TestClient_PutRaw(t *testing.T) { + payload := []byte("binary image data here") + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + ct := r.Header.Get("Content-Type") + if ct != "image/png" { + t.Errorf("Content-Type = %q, want image/png", ct) + } + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + t.Errorf("expected Bearer token, got %q", authHeader) + } + body := make([]byte, 1024) + n, _ := r.Body.Read(body) + if string(body[:n]) != string(payload) { + t.Errorf("body = %q, want %q", body[:n], payload) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + if err := client.PutRaw("/media/test.png", payload, "image/png"); err != nil { + t.Fatalf("PutRaw() error: %v", err) + } +} + +func TestClient_PutRaw_Error(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewClient(srv.URL, tm) + + err := client.PutRaw("/media/test.xyz", []byte("data"), "application/octet-stream") + if err == nil { + t.Fatal("expected error for 415 response, got nil") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != http.StatusUnsupportedMediaType { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusUnsupportedMediaType) + } +} + +func TestXMLClient_NonXMLBodyReturnsError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "tok", "expires_in": 3600}) + })) + defer tokenSrv.Close() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tm := auth.NewTokenManager("client-id", "client-secret", tokenSrv.URL) + client := NewXMLClient(srv.URL, tm) + + // Passing a plain map (not XMLBody) to an XML client should return an error. + err := client.Post("/test", map[string]string{"key": "val"}, nil) + if err == nil { + t.Fatal("expected error when passing non-XMLBody to XML client, got nil") + } + if !strings.Contains(err.Error(), "XMLBody") { + t.Errorf("expected error mentioning XMLBody, got: %v", err) + } +} diff --git a/internal/api/xml.go b/internal/api/xml.go new file mode 100644 index 0000000..c0531a0 --- /dev/null +++ b/internal/api/xml.go @@ -0,0 +1,176 @@ +package api + +import ( + "bytes" + "encoding/xml" + "fmt" + "sort" +) + +// XMLBody wraps a request body with the XML root element name. +// Dashboard API commands use this when calling Post/Put on an XML client. +type XMLBody struct { + RootElement string + Data map[string]interface{} +} + +// MapToXML serializes a flat (or nested) Go map to XML with the given root element. +// Values that are maps are recursively serialized as child elements. +// Slice values produce repeated elements with the same tag. +func MapToXML(rootElement string, data map[string]interface{}) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(xml.Header) + + if err := writeMapAsElement(&buf, rootElement, data); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// writeMapAsElement writes ...children... where children come from the map. +func writeMapAsElement(buf *bytes.Buffer, tag string, data map[string]interface{}) error { + buf.WriteString("<" + tag + ">") + + // Sort keys for deterministic output. + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if err := writeValue(buf, k, data[k]); err != nil { + return err + } + } + + buf.WriteString("") + return nil +} + +// writeValue writes a single key-value pair as XML. +func writeValue(buf *bytes.Buffer, tag string, value interface{}) error { + switch v := value.(type) { + case map[string]interface{}: + return writeMapAsElement(buf, tag, v) + + case map[string]string: + buf.WriteString("<" + tag + ">") + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + buf.WriteString("<" + k + ">") + xml.EscapeText(buf, []byte(v[k])) //nolint:errcheck + buf.WriteString("") + } + buf.WriteString("") + + case []interface{}: + for _, item := range v { + if err := writeValue(buf, tag, item); err != nil { + return err + } + } + + case []string: + for _, s := range v { + buf.WriteString("<" + tag + ">") + xml.EscapeText(buf, []byte(s)) //nolint:errcheck + buf.WriteString("") + } + + default: + buf.WriteString("<" + tag + ">") + xml.EscapeText(buf, []byte(fmt.Sprintf("%v", v))) //nolint:errcheck + buf.WriteString("") + } + + return nil +} + +// XMLToMap parses an XML document into a nested map[string]interface{}. +// The returned map uses the root element as a top-level key, so callers get +// the full structure including the root element name. +// Repeated sibling elements with the same tag are collected into []interface{}. +// Text-only elements are represented as strings. +func XMLToMap(data []byte) (map[string]interface{}, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + result, err := decodeNextElement(decoder) + if err != nil { + return nil, fmt.Errorf("parsing XML: %w", err) + } + if m, ok := result.(map[string]interface{}); ok { + return m, nil + } + return map[string]interface{}{"value": fmt.Sprintf("%v", result)}, nil +} + +// decodeNextElement advances the decoder to the next start element and decodes it. +func decodeNextElement(decoder *xml.Decoder) (interface{}, error) { + for { + tok, err := decoder.Token() + if err != nil { + return nil, err + } + if start, ok := tok.(xml.StartElement); ok { + return decodeElement(decoder, start) + } + } +} + +// decodeElement decodes the content of an already-opened start element and +// returns map[string]interface{}{tagName: content} where content is either a +// string (text-only), or a map[string]interface{} (has child elements). +func decodeElement(decoder *xml.Decoder, start xml.StartElement) (interface{}, error) { + children := map[string]interface{}{} + var textBuf bytes.Buffer + hasChildren := false + + for { + tok, err := decoder.Token() + if err != nil { + return nil, err + } + + switch t := tok.(type) { + case xml.StartElement: + hasChildren = true + child, err := decodeElement(decoder, t) + if err != nil { + return nil, err + } + // child is map[string]interface{}{childTag: childContent} + childMap, ok := child.(map[string]interface{}) + if !ok { + continue + } + for key, val := range childMap { + if existing, exists := children[key]; exists { + switch e := existing.(type) { + case []interface{}: + children[key] = append(e, val) + default: + children[key] = []interface{}{e, val} + } + } else { + children[key] = val + } + } + + case xml.CharData: + textBuf.Write(t) + + case xml.EndElement: + tag := start.Name.Local + if !hasChildren { + text := string(bytes.TrimSpace(textBuf.Bytes())) + return map[string]interface{}{tag: text}, nil + } + return map[string]interface{}{tag: children}, nil + } + } +} diff --git a/internal/api/xml_test.go b/internal/api/xml_test.go new file mode 100644 index 0000000..d5bd783 --- /dev/null +++ b/internal/api/xml_test.go @@ -0,0 +1,257 @@ +package api + +import ( + "strings" + "testing" +) + +// ---- MapToXML tests ---- + +func TestMapToXML_FlatMap(t *testing.T) { + data := map[string]interface{}{ + "PeerName": "My Location", + "IsDefaultPeer": "true", + } + got, err := MapToXML("SipPeer", data) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + s := string(got) + if !strings.Contains(s, "") { + t.Errorf("expected root element, got:\n%s", s) + } + if !strings.Contains(s, "My Location") { + t.Errorf("expected PeerName element, got:\n%s", s) + } + if !strings.Contains(s, "true") { + t.Errorf("expected IsDefaultPeer element, got:\n%s", s) + } + if !strings.Contains(s, "") { + t.Errorf("expected closing , got:\n%s", s) + } +} + +func TestMapToXML_NestedMap(t *testing.T) { + data := map[string]interface{}{ + "TelephoneNumberList": map[string]interface{}{ + "TelephoneNumber": "5551234567", + }, + } + got, err := MapToXML("Order", data) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + s := string(got) + if !strings.Contains(s, "") { + t.Errorf("expected root, got:\n%s", s) + } + if !strings.Contains(s, "") { + t.Errorf("expected , got:\n%s", s) + } + if !strings.Contains(s, "5551234567") { + t.Errorf("expected , got:\n%s", s) + } +} + +func TestMapToXML_SliceValues(t *testing.T) { + data := map[string]interface{}{ + "TelephoneNumberList": map[string]interface{}{ + "TelephoneNumber": []string{"5551111111", "5552222222"}, + }, + } + got, err := MapToXML("Order", data) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + s := string(got) + if !strings.Contains(s, "5551111111") { + t.Errorf("expected first TelephoneNumber, got:\n%s", s) + } + if !strings.Contains(s, "5552222222") { + t.Errorf("expected second TelephoneNumber, got:\n%s", s) + } +} + +func TestMapToXML_ApplicationFields(t *testing.T) { + data := map[string]interface{}{ + "AppName": "My App", + "CallInitiatedCallbackUrl": "https://example.com/voice", + "ServiceType": "Voice-V2", + } + got, err := MapToXML("Application", data) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + s := string(got) + if !strings.Contains(s, "") { + t.Errorf("expected root, got:\n%s", s) + } + if !strings.Contains(s, "My App") { + t.Errorf("expected AppName, got:\n%s", s) + } + if !strings.Contains(s, "Voice-V2") { + t.Errorf("expected ServiceType, got:\n%s", s) + } +} + +func TestMapToXML_XMLEscaping(t *testing.T) { + data := map[string]interface{}{ + "Name": "Site <>&\"", + } + got, err := MapToXML("Site", data) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + s := string(got) + // The special chars should be XML-escaped. + if strings.Contains(s, "<>&\"") { + t.Errorf("expected XML-escaped content, got unescaped:\n%s", s) + } + if !strings.Contains(s, "<") && !strings.Contains(s, "&#") { + t.Errorf("expected escaped < in output, got:\n%s", s) + } +} + +// ---- XMLToMap tests ---- + +func TestXMLToMap_Simple(t *testing.T) { + input := ` + + My Location + false +` + + got, err := XMLToMap([]byte(input)) + if err != nil { + t.Fatalf("XMLToMap() error: %v", err) + } + + root, ok := got["SipPeer"] + if !ok { + t.Fatalf("expected SipPeer key in result, got: %v", got) + } + m, ok := root.(map[string]interface{}) + if !ok { + t.Fatalf("expected SipPeer value to be a map, got %T", root) + } + pn, ok := m["PeerName"] + if !ok { + t.Fatal("expected PeerName in SipPeer") + } + if pn != "My Location" { + t.Errorf("PeerName = %q, want %q", pn, "My Location") + } +} + +func TestXMLToMap_Nested(t *testing.T) { + input := ` + + + + 5551234567 + + +` + + got, err := XMLToMap([]byte(input)) + if err != nil { + t.Fatalf("XMLToMap() error: %v", err) + } + + root, ok := got["OrderResponse"] + if !ok { + t.Fatalf("expected OrderResponse key, got: %v", got) + } + rootMap, ok := root.(map[string]interface{}) + if !ok { + t.Fatalf("expected map under OrderResponse, got %T", root) + } + order, ok := rootMap["Order"] + if !ok { + t.Fatalf("expected Order key, got: %v", rootMap) + } + _ = order // nested structure present +} + +func TestXMLToMap_RepeatedElements(t *testing.T) { + input := ` + + 5551111111 + 5552222222 +` + + got, err := XMLToMap([]byte(input)) + if err != nil { + t.Fatalf("XMLToMap() error: %v", err) + } + + root, ok := got["TelephoneNumberList"] + if !ok { + t.Fatalf("expected TelephoneNumberList key, got: %v", got) + } + rootMap, ok := root.(map[string]interface{}) + if !ok { + t.Fatalf("expected map under TelephoneNumberList, got %T", root) + } + nums, ok := rootMap["TelephoneNumber"] + if !ok { + t.Fatal("expected TelephoneNumber key") + } + slice, ok := nums.([]interface{}) + if !ok { + t.Fatalf("expected []interface{} for repeated elements, got %T", nums) + } + if len(slice) != 2 { + t.Errorf("expected 2 TelephoneNumber entries, got %d", len(slice)) + } +} + +func TestXMLToMap_EmptyElement(t *testing.T) { + input := `My Site` + got, err := XMLToMap([]byte(input)) + if err != nil { + t.Fatalf("XMLToMap() error: %v", err) + } + if _, ok := got["Site"]; !ok { + t.Errorf("expected Site key, got: %v", got) + } +} + +// ---- Round-trip test ---- + +func TestMapToXML_XMLToMap_Roundtrip(t *testing.T) { + original := map[string]interface{}{ + "AppName": "Test App", + "ServiceType": "Voice-V2", + } + + xmlBytes, err := MapToXML("Application", original) + if err != nil { + t.Fatalf("MapToXML() error: %v", err) + } + + parsed, err := XMLToMap(xmlBytes) + if err != nil { + t.Fatalf("XMLToMap() error: %v", err) + } + + root, ok := parsed["Application"] + if !ok { + t.Fatalf("expected Application key after roundtrip, got: %v", parsed) + } + m, ok := root.(map[string]interface{}) + if !ok { + t.Fatalf("expected map under Application, got %T", root) + } + if m["AppName"] != "Test App" { + t.Errorf("AppName = %v, want %q", m["AppName"], "Test App") + } + if m["ServiceType"] != "Voice-V2" { + t.Errorf("ServiceType = %v, want %q", m["ServiceType"], "Voice-V2") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e8b5a8c --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,47 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/zalando/go-keyring" +) + +const serviceName = "band-cli" + +// StorePassword stores password for username in the OS keychain. +func StorePassword(username, password string) error { + return keyring.Set(serviceName, username, password) +} + +// GetPassword retrieves the password for username from the OS keychain. +func GetPassword(username string) (string, error) { + return keyring.Get(serviceName, username) +} + +// DeletePassword removes the credential for username from the OS keychain. +func DeletePassword(username string) error { + return keyring.Delete(serviceName, username) +} + +// EncodeBasicAuth returns the Base64 encoding of "username:password". +func EncodeBasicAuth(username, password string) string { + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// DecodeBasicAuth decodes a Base64-encoded "username:password" string and +// returns the username and password separately. +func DecodeBasicAuth(encoded string) (string, string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", "", fmt.Errorf("decoding basic auth: %w", err) + } + + parts := strings.SplitN(string(data), ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid basic auth format: missing colon separator") + } + + return parts[0], parts[1], nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..efc391e --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,99 @@ +package auth + +import ( + "encoding/base64" + "testing" +) + +func TestEncodeBasicAuth(t *testing.T) { + // "user:pass" base64-encodes to "dXNlcjpwYXNz" + got := EncodeBasicAuth("user", "pass") + want := "dXNlcjpwYXNz" + if got != want { + t.Errorf("EncodeBasicAuth(%q, %q) = %q, want %q", "user", "pass", got, want) + } +} + +func TestEncodeBasicAuthEmptyFields(t *testing.T) { + got := EncodeBasicAuth("", "") + if got == "" { + t.Error("EncodeBasicAuth(\"\", \"\") returned empty string") + } +} + +func TestDecodeBasicAuthRoundTrip(t *testing.T) { + username := "myuser@bandwidth.com" + password := "s3cr3tP@ss!" + + encoded := EncodeBasicAuth(username, password) + gotUser, gotPass, err := DecodeBasicAuth(encoded) + if err != nil { + t.Fatalf("DecodeBasicAuth() error: %v", err) + } + if gotUser != username { + t.Errorf("username = %q, want %q", gotUser, username) + } + if gotPass != password { + t.Errorf("password = %q, want %q", gotPass, password) + } +} + +func TestDecodeBasicAuthKnownValue(t *testing.T) { + // "dXNlcjpwYXNz" decodes to "user:pass" + user, pass, err := DecodeBasicAuth("dXNlcjpwYXNz") + if err != nil { + t.Fatalf("DecodeBasicAuth() error: %v", err) + } + if user != "user" { + t.Errorf("user = %q, want %q", user, "user") + } + if pass != "pass" { + t.Errorf("pass = %q, want %q", pass, "pass") + } +} + +func TestDecodeBasicAuthInvalidBase64(t *testing.T) { + _, _, err := DecodeBasicAuth("not-valid-base64!!!") + if err == nil { + t.Error("expected error for invalid base64, got nil") + } +} + +func TestDecodeBasicAuthMissingColon(t *testing.T) { + // Encode a string with no colon separator + noColon := base64.StdEncoding.EncodeToString([]byte("nocolon")) + _, _, err := DecodeBasicAuth(noColon) + if err == nil { + t.Error("expected error when no colon separator, got nil") + } +} + +func TestKeyringStoreAndGet(t *testing.T) { + // Keyring operations may fail in CI environments without a keychain. + // Skip gracefully if the keychain isn't available. + username := "keyring-test-user" + password := "keyring-test-pass" + + err := StorePassword(username, password) + if err != nil { + t.Skipf("Keychain not available (StorePassword error): %v", err) + } + + got, err := GetPassword(username) + if err != nil { + t.Fatalf("GetPassword() error: %v", err) + } + if got != password { + t.Errorf("GetPassword() = %q, want %q", got, password) + } + + // Cleanup + if err := DeletePassword(username); err != nil { + t.Logf("DeletePassword() warning: %v", err) + } +} + +func TestKeyringDeleteNonexistent(t *testing.T) { + // Deleting something that was never stored — should not panic. + _ = DeletePassword("band-cli-nonexistent-user-xyz") +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..e984b67 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,103 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// TokenManager handles fetching and caching OAuth2 client credentials tokens. +type TokenManager struct { + ClientID string + ClientSecret string + TokenURL string + + token string + expiresAt time.Time + mu sync.Mutex +} + +// NewTokenManager creates a TokenManager for the given client credentials and token URL. +func NewTokenManager(clientID, clientSecret, tokenURL string) *TokenManager { + return &TokenManager{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenURL, + } +} + +// tokenResponse is the JSON body returned by the OAuth2 token endpoint. +type tokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// GetToken returns a valid Bearer token, fetching a new one if the cached token +// is missing or within 1 minute of expiry. +func (tm *TokenManager) GetToken() (string, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + // Return cached token if it has more than 1 minute remaining. + if tm.token != "" && time.Now().Add(time.Minute).Before(tm.expiresAt) { + return tm.token, nil + } + + return tm.fetchToken() +} + +// fetchToken performs the token exchange. Caller must hold tm.mu. +func (tm *TokenManager) fetchToken() (string, error) { + form := url.Values{} + form.Set("grant_type", "client_credentials") + + req, err := http.NewRequest(http.MethodPost, tm.TokenURL+"/api/v1/oauth2/token", strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("creating token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+EncodeBasicAuth(tm.ClientID, tm.ClientSecret)) + req.Header.Set("User-Agent", "band-cli/0.1.0") + + httpClient := &http.Client{Timeout: 15 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading token response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("token exchange failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return "", fmt.Errorf("parsing token response: %w", err) + } + + if tr.AccessToken == "" { + return "", fmt.Errorf("token response missing access_token") + } + + tm.token = tr.AccessToken + if tr.ExpiresIn > 0 { + tm.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + } else { + // Default to 1 hour if no expiry is given. + tm.expiresAt = time.Now().Add(time.Hour) + } + + return tm.token, nil +} diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go new file mode 100644 index 0000000..1d6cfd5 --- /dev/null +++ b/internal/auth/oauth_test.go @@ -0,0 +1,187 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func newTokenServer(t *testing.T, token string, expiresIn int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify method and Content-Type + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct) + } + // Verify Authorization header contains Basic auth + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Basic ") { + t.Errorf("Authorization = %q, want Basic ...", authHeader) + } + // Verify grant_type in body + if err := r.ParseForm(); err != nil { + t.Errorf("parsing form: %v", err) + } + if gt := r.FormValue("grant_type"); gt != "client_credentials" { + t.Errorf("grant_type = %q, want client_credentials", gt) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": token, + "expires_in": expiresIn, + "token_type": "bearer", + }) + })) +} + +func TestTokenManager_GetToken(t *testing.T) { + srv := newTokenServer(t, "my-access-token", 3600) + defer srv.Close() + + tm := NewTokenManager("client-id", "client-secret", srv.URL) + token, err := tm.GetToken() + if err != nil { + t.Fatalf("GetToken() error: %v", err) + } + if token != "my-access-token" { + t.Errorf("token = %q, want %q", token, "my-access-token") + } +} + +func TestTokenManager_CachesToken(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "cached-token", + "expires_in": 3600, + "token_type": "bearer", + }) + })) + defer srv.Close() + + tm := NewTokenManager("client-id", "client-secret", srv.URL) + + // Call GetToken twice — should only hit the server once. + if _, err := tm.GetToken(); err != nil { + t.Fatalf("first GetToken() error: %v", err) + } + if _, err := tm.GetToken(); err != nil { + t.Fatalf("second GetToken() error: %v", err) + } + + if callCount != 1 { + t.Errorf("token server called %d times, want 1 (cached after first call)", callCount) + } +} + +func TestTokenManager_RefreshesExpiredToken(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "refreshed-token", + "expires_in": 3600, + "token_type": "bearer", + }) + })) + defer srv.Close() + + tm := NewTokenManager("client-id", "client-secret", srv.URL) + + // Seed the manager with an already-expired token. + tm.token = "old-token" + tm.expiresAt = time.Now().Add(-10 * time.Second) + + token, err := tm.GetToken() + if err != nil { + t.Fatalf("GetToken() error: %v", err) + } + if token != "refreshed-token" { + t.Errorf("token = %q, want %q", token, "refreshed-token") + } + if callCount != 1 { + t.Errorf("token server called %d times, want 1", callCount) + } +} + +func TestTokenManager_RefreshesTokenNearExpiry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "new-token", + "expires_in": 3600, + }) + })) + defer srv.Close() + + tm := NewTokenManager("client-id", "client-secret", srv.URL) + + // Seed with a token that expires in 30 seconds (within the 1-minute buffer). + tm.token = "nearly-expired" + tm.expiresAt = time.Now().Add(30 * time.Second) + + token, err := tm.GetToken() + if err != nil { + t.Fatalf("GetToken() error: %v", err) + } + if token != "new-token" { + t.Errorf("expected refresh; got %q, want %q", token, "new-token") + } +} + +func TestTokenManager_ErrorResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + defer srv.Close() + + tm := NewTokenManager("bad-id", "bad-secret", srv.URL) + _, err := tm.GetToken() + if err == nil { + t.Fatal("expected error for 401 response, got nil") + } +} + +func TestTokenManager_ThreadSafe(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "concurrent-token", + "expires_in": 3600, + }) + })) + defer srv.Close() + + tm := NewTokenManager("client-id", "client-secret", srv.URL) + + var wg sync.WaitGroup + errs := make(chan error, 20) + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := tm.GetToken(); err != nil { + errs <- err + } + }() + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Errorf("concurrent GetToken() error: %v", err) + } +} diff --git a/internal/cmdutil/exitcodes.go b/internal/cmdutil/exitcodes.go new file mode 100644 index 0000000..8e83b89 --- /dev/null +++ b/internal/cmdutil/exitcodes.go @@ -0,0 +1,38 @@ +package cmdutil + +import ( + "errors" + + "github.com/Bandwidth/cli/internal/api" +) + +// Exit code constants for the bw CLI. +const ( + ExitOK = 0 + ExitGeneral = 1 + ExitAuth = 2 + ExitNotFound = 3 + ExitConflict = 4 + ExitTimeout = 5 + ExitFlagError = 6 +) + +// ExitCodeForError maps an error to the appropriate exit code. +// API errors are mapped by HTTP status code; all other errors get ExitGeneral. +func ExitCodeForError(err error) int { + if err == nil { + return ExitOK + } + var apiErr *api.APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case 401, 403: + return ExitAuth + case 404: + return ExitNotFound + case 409: + return ExitConflict + } + } + return ExitGeneral +} diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go new file mode 100644 index 0000000..d727bf9 --- /dev/null +++ b/internal/cmdutil/helpers.go @@ -0,0 +1,183 @@ +// Package cmdutil provides shared helpers for CLI command implementations. +package cmdutil + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/auth" + "github.com/Bandwidth/cli/internal/config" +) + +// apiHostForEnvironment maps an environment name to its API host. +// Non-production environments can be overridden with BW_API_URL. +func apiHostForEnvironment(env string) string { + if v := os.Getenv("BW_API_URL"); v != "" { + return strings.TrimRight(v, "/") + } + switch env { + case "test", "uat": + return "https://test.api.bandwidth.com" + default: // prod or empty + return "https://api.bandwidth.com" + } +} + +// voiceHostForEnvironment maps an environment name to its Voice API host. +// Non-production environments can be overridden with BW_VOICE_URL. +func voiceHostForEnvironment(env string) string { + if v := os.Getenv("BW_VOICE_URL"); v != "" { + return strings.TrimRight(v, "/") + } + switch env { + case "test", "uat": + return "https://test.voice.bandwidth.com" + default: + return "https://voice.bandwidth.com" + } +} + +// loadConfigAndAuth loads the config, retrieves the client secret, and returns +// everything needed to build an API client. +func loadConfigAndAuth() (*config.Config, *config.Profile, string, error) { + configPath, err := config.DefaultPath() + if err != nil { + return nil, nil, "", fmt.Errorf("resolving config path: %w", err) + } + + cfg, err := config.Load(configPath) + if err != nil { + return nil, nil, "", fmt.Errorf("loading config: %w", err) + } + + p := cfg.ActiveProfileConfig() + if p.ClientID == "" { + return nil, nil, "", fmt.Errorf("not logged in — run `band auth login` first") + } + + clientSecret, err := auth.GetPassword(p.ClientID) + if err != nil { + return nil, nil, "", fmt.Errorf("credentials not found in keychain for %s — run `band auth login`", p.ClientID) + } + + return cfg, p, clientSecret, nil +} + +// resolveAccountID resolves the account ID from override > env > config, +// returning an actionable error if none is set. +func resolveAccountID(cfg *config.Config, p *config.Profile, accountIDOverride string) (string, error) { + acctID := accountIDOverride + if acctID == "" { + acctID = os.Getenv("BW_ACCOUNT_ID") + } + if acctID == "" { + acctID = p.AccountID + } + if acctID != "" { + return acctID, nil + } + + // No account ID found — build a helpful error + profileName := cfg.ActiveProfile + if profileName == "" { + profileName = "default" + } + + if len(p.Accounts) > 0 { + return "", fmt.Errorf("no active account set for profile %q.\n"+ + "Available accounts: %s\n"+ + "Run: band auth switch \n"+ + "Or pass --account-id on this command", + profileName, strings.Join(p.Accounts, ", ")) + } + + return "", fmt.Errorf("no account ID set for profile %q.\n"+ + "This credential has system-wide access — pass --account-id on this command.\n"+ + "Hint: use the default profile's accounts: band auth use default && band auth status", + profileName) +} + +// authenticate loads config, resolves the account, and returns a token manager +// plus the resolved environment and account ID. +func authenticate(accountIDOverride string) (*auth.TokenManager, string, string, error) { + cfg, p, clientSecret, err := loadConfigAndAuth() + if err != nil { + return nil, "", "", err + } + + acctID, err := resolveAccountID(cfg, p, accountIDOverride) + if err != nil { + return nil, "", "", err + } + + apiHost := apiHostForEnvironment(p.Environment) + tm := auth.NewTokenManager(p.ClientID, clientSecret, apiHost) + return tm, acctID, p.Environment, nil +} + +// BuildClient returns an authenticated JSON API client. +func BuildClient(apiBaseURL, accountIDOverride string) (*api.Client, string, error) { + tm, acctID, _, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewClient(apiBaseURL, tm), acctID, nil +} + +// BuildXMLClient returns an authenticated XML-mode client for the Dashboard API. +func BuildXMLClient(apiBaseURL, accountIDOverride string) (*api.Client, string, error) { + tm, acctID, _, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewXMLClient(apiBaseURL, tm), acctID, nil +} + +// OutputFlags extracts the common --plain and --format flags from a command's root. +func OutputFlags(cmd *cobra.Command) (format string, plain bool) { + plain = cmd.Root().Flag("plain").Value.String() == "true" + format = cmd.Root().Flag("format").Value.String() + return format, plain +} + +// AccountIDFlag extracts the --account-id override from a command's root. +func AccountIDFlag(cmd *cobra.Command) string { + return cmd.Root().Flag("account-id").Value.String() +} + +// DashboardClient returns an XML-mode client for the Bandwidth Dashboard API v2. +func DashboardClient(accountIDOverride string) (*api.Client, string, error) { + tm, acctID, env, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewXMLClient(apiHostForEnvironment(env)+"/api/v2", tm), acctID, nil +} + +// VoiceClient returns a client for the Bandwidth Voice API v2. +func VoiceClient(accountIDOverride string) (*api.Client, string, error) { + tm, acctID, env, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewClient(voiceHostForEnvironment(env)+"/api/v2", tm), acctID, nil +} + +// PlatformClient creates a JSON API client for Universal Platform v2 endpoints (e.g. VCP). +func PlatformClient(accountIDOverride string) (*api.Client, string, error) { + tm, acctID, env, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewClient(apiHostForEnvironment(env), tm), acctID, nil +} + +// MessagingClient returns a client for the Bandwidth Messaging API v2. +func MessagingClient(accountIDOverride string) (*api.Client, string, error) { + return BuildClient("https://messaging.bandwidth.com/api/v2", accountIDOverride) +} + diff --git a/internal/cmdutil/helpers_test.go b/internal/cmdutil/helpers_test.go new file mode 100644 index 0000000..224fc6d --- /dev/null +++ b/internal/cmdutil/helpers_test.go @@ -0,0 +1,63 @@ +package cmdutil + +import "testing" + +func TestVoiceHostForEnvironment(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {"prod default", "", "https://voice.bandwidth.com"}, + {"prod explicit", "prod", "https://voice.bandwidth.com"}, + {"unknown env falls back to prod", "other", "https://voice.bandwidth.com"}, + {"test", "test", "https://test.voice.bandwidth.com"}, + {"uat", "uat", "https://test.voice.bandwidth.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := voiceHostForEnvironment(tt.env); got != tt.want { + t.Errorf("voiceHostForEnvironment(%q) = %q, want %q", tt.env, got, tt.want) + } + }) + } +} + +func TestVoiceHostForEnvironment_BW_VOICE_URL(t *testing.T) { + t.Setenv("BW_VOICE_URL", "https://custom.voice.example.com") + for _, env := range []string{"", "prod", "test"} { + got := voiceHostForEnvironment(env) + if got != "https://custom.voice.example.com" { + t.Errorf("voiceHostForEnvironment(%q) with BW_VOICE_URL = %q, want override", env, got) + } + } +} + +func TestVoiceHostForEnvironment_BW_VOICE_URL_TrailingSlash(t *testing.T) { + t.Setenv("BW_VOICE_URL", "https://custom.voice.example.com/") + got := voiceHostForEnvironment("") + if got != "https://custom.voice.example.com" { + t.Errorf("voiceHostForEnvironment with trailing slash = %q, want without slash", got) + } +} + +func TestAPIHostForEnvironment(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {"prod default", "", "https://api.bandwidth.com"}, + {"prod explicit", "prod", "https://api.bandwidth.com"}, + {"unknown env falls back to prod", "other", "https://api.bandwidth.com"}, + {"test", "test", "https://test.api.bandwidth.com"}, + {"uat", "uat", "https://test.api.bandwidth.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := apiHostForEnvironment(tt.env); got != tt.want { + t.Errorf("apiHostForEnvironment(%q) = %q, want %q", tt.env, got, tt.want) + } + }) + } +} diff --git a/internal/cmdutil/numbertype.go b/internal/cmdutil/numbertype.go new file mode 100644 index 0000000..9c0e0a1 --- /dev/null +++ b/internal/cmdutil/numbertype.go @@ -0,0 +1,61 @@ +package cmdutil + +import "strings" + +// NumberType represents the type of a phone number for messaging purposes. +type NumberType int + +const ( + NumberTypeUnknown NumberType = iota + NumberType10DLC // Local 10-digit US/CA number + NumberTypeTollFree // US/CA toll-free (800, 888, 877, 866, 855, 844, 833) + NumberTypeShortCode // 5-6 digit short code +) + +func (t NumberType) String() string { + switch t { + case NumberType10DLC: + return "10DLC" + case NumberTypeTollFree: + return "toll-free" + case NumberTypeShortCode: + return "short code" + default: + return "unknown" + } +} + +// tollFreePrefixes are the US/CA toll-free area codes. +var tollFreePrefixes = []string{"800", "888", "877", "866", "855", "844", "833"} + +// NormalizeNumber ensures a phone number has the + prefix for E.164 format. +func NormalizeNumber(number string) string { + if !strings.HasPrefix(number, "+") { + return "+" + number + } + return number +} + +// ClassifyNumber determines the number type from an E.164-formatted phone number. +func ClassifyNumber(number string) NumberType { + // Strip the + prefix if present + n := strings.TrimPrefix(number, "+") + + // Short codes are 5-6 digits (no country code prefix) + if len(n) >= 5 && len(n) <= 6 { + return NumberTypeShortCode + } + + // US/CA numbers start with 1 and are 11 digits total + if len(n) == 11 && strings.HasPrefix(n, "1") { + areaCode := n[1:4] + for _, prefix := range tollFreePrefixes { + if areaCode == prefix { + return NumberTypeTollFree + } + } + return NumberType10DLC + } + + return NumberTypeUnknown +} diff --git a/internal/cmdutil/numbertype_test.go b/internal/cmdutil/numbertype_test.go new file mode 100644 index 0000000..2d44315 --- /dev/null +++ b/internal/cmdutil/numbertype_test.go @@ -0,0 +1,61 @@ +package cmdutil + +import "testing" + +func TestClassifyNumber(t *testing.T) { + tests := []struct { + number string + want NumberType + }{ + // 10DLC (local US numbers) + {"+19195551234", NumberType10DLC}, + {"+17045551234", NumberType10DLC}, + {"19195551234", NumberType10DLC}, + + // Toll-free + {"+18005551234", NumberTypeTollFree}, + {"+18885551234", NumberTypeTollFree}, + {"+18775551234", NumberTypeTollFree}, + {"+18665551234", NumberTypeTollFree}, + {"+18555551234", NumberTypeTollFree}, + {"+18445551234", NumberTypeTollFree}, + {"+18335551234", NumberTypeTollFree}, + {"18005551234", NumberTypeTollFree}, + + // Short codes + {"12345", NumberTypeShortCode}, + {"123456", NumberTypeShortCode}, + {"+12345", NumberTypeShortCode}, + + // Unknown / international + {"+441234567890", NumberTypeUnknown}, + {"+49301234567", NumberTypeUnknown}, + {"", NumberTypeUnknown}, + } + + for _, tc := range tests { + t.Run(tc.number, func(t *testing.T) { + got := ClassifyNumber(tc.number) + if got != tc.want { + t.Errorf("ClassifyNumber(%q) = %v, want %v", tc.number, got, tc.want) + } + }) + } +} + +func TestNumberType_String(t *testing.T) { + tests := []struct { + nt NumberType + want string + }{ + {NumberType10DLC, "10DLC"}, + {NumberTypeTollFree, "toll-free"}, + {NumberTypeShortCode, "short code"}, + {NumberTypeUnknown, "unknown"}, + } + for _, tc := range tests { + if got := tc.nt.String(); got != tc.want { + t.Errorf("%d.String() = %q, want %q", tc.nt, got, tc.want) + } + } +} diff --git a/internal/cmdutil/poll.go b/internal/cmdutil/poll.go new file mode 100644 index 0000000..275522e --- /dev/null +++ b/internal/cmdutil/poll.go @@ -0,0 +1,36 @@ +package cmdutil + +import ( + "fmt" + "time" +) + +// PollConfig configures a polling loop. +type PollConfig struct { + Interval time.Duration + Timeout time.Duration + // Check performs one poll attempt. It should return done=true when the + // desired condition is met, along with the final result. Return an error + // only for hard failures (not for "not ready yet"). + Check func() (done bool, result interface{}, err error) +} + +// Poll runs cfg.Check repeatedly at cfg.Interval until it returns done=true or +// cfg.Timeout is exceeded. On success it returns the result from Check. +// On timeout it returns ErrPollTimeout. +func Poll(cfg PollConfig) (interface{}, error) { + deadline := time.Now().Add(cfg.Timeout) + for { + done, result, err := cfg.Check() + if err != nil { + return nil, err + } + if done { + return result, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out after %s waiting for operation to complete", cfg.Timeout) + } + time.Sleep(cfg.Interval) + } +} diff --git a/internal/cmdutil/terminal.go b/internal/cmdutil/terminal.go new file mode 100644 index 0000000..1eba28c --- /dev/null +++ b/internal/cmdutil/terminal.go @@ -0,0 +1,19 @@ +//go:build !windows + +package cmdutil + +import ( + "syscall" + + "golang.org/x/term" +) + +// IsInteractive reports whether stdin is a terminal. +func IsInteractive() bool { + return term.IsTerminal(syscall.Stdin) +} + +// ReadPassword reads a password from stdin without echoing. +func ReadPassword() ([]byte, error) { + return term.ReadPassword(syscall.Stdin) +} diff --git a/internal/cmdutil/terminal_windows.go b/internal/cmdutil/terminal_windows.go new file mode 100644 index 0000000..6d88a7a --- /dev/null +++ b/internal/cmdutil/terminal_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package cmdutil + +import ( + "syscall" + + "golang.org/x/term" +) + +// IsInteractive reports whether stdin is a terminal. +func IsInteractive() bool { + return term.IsTerminal(int(syscall.Stdin)) +} + +// ReadPassword reads a password from stdin without echoing. +func ReadPassword() ([]byte, error) { + return term.ReadPassword(int(syscall.Stdin)) +} diff --git a/internal/cmdutil/validate.go b/internal/cmdutil/validate.go new file mode 100644 index 0000000..662e151 --- /dev/null +++ b/internal/cmdutil/validate.go @@ -0,0 +1,26 @@ +package cmdutil + +import ( + "fmt" + "strings" +) + +// ValidateID checks that a user-supplied ID does not contain characters that +// could inject path segments or query parameters into a URL. The forbidden +// set covers the most common path-traversal and query-injection characters: +// slash, question mark, ampersand, hash, percent, and any ASCII whitespace. +func ValidateID(id string) error { + const forbidden = "/?&#%" + if strings.ContainsAny(id, forbidden) { + return fmt.Errorf("invalid ID %q: must not contain '/', '?', '&', '#', or '%%'", id) + } + for _, r := range id { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return fmt.Errorf("invalid ID %q: must not contain whitespace", id) + } + } + if id == "" { + return fmt.Errorf("ID must not be empty") + } + return nil +} diff --git a/internal/cmdutil/validate_test.go b/internal/cmdutil/validate_test.go new file mode 100644 index 0000000..dbccef2 --- /dev/null +++ b/internal/cmdutil/validate_test.go @@ -0,0 +1,61 @@ +package cmdutil + +import ( + "testing" +) + +func TestValidateID_Valid(t *testing.T) { + valid := []string{ + "abc-123", + "d27b5ce6-167f-4664-9c03-c60b472f6fae", + "152681", + "some_id", + "c-8605e2ca-022696ef", + } + for _, id := range valid { + if err := ValidateID(id); err != nil { + t.Errorf("ValidateID(%q) returned unexpected error: %v", id, err) + } + } +} + +func TestValidateID_Empty(t *testing.T) { + err := ValidateID("") + if err == nil { + t.Fatal("expected error for empty ID") + } + if err.Error() != "ID must not be empty" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestValidateID_ForbiddenChars(t *testing.T) { + forbidden := []string{ + "abc/def", + "id?param=1", + "id&other", + "id#frag", + "id%20encoded", + } + for _, id := range forbidden { + err := ValidateID(id) + if err == nil { + t.Errorf("ValidateID(%q) should have returned error", id) + } + } +} + +func TestValidateID_Whitespace(t *testing.T) { + whitespace := []string{ + "id with space", + "id\twith\ttab", + "id\nwith\nnewline", + "id\rwith\rreturn", + } + for _, id := range whitespace { + err := ValidateID(id) + if err == nil { + t.Errorf("ValidateID(%q) should have returned error for whitespace", id) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..73b3426 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,179 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// Profile holds credentials and account info for a single set of API credentials. +type Profile struct { + ClientID string `json:"client_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Accounts []string `json:"accounts,omitempty"` + Environment string `json:"environment,omitempty"` // prod, test +} + +// Config holds the CLI configuration values persisted to ~/.band/config.json. +type Config struct { + // Active profile name + ActiveProfile string `json:"active_profile,omitempty"` + + // Named profiles (keyed by profile name) + Profiles map[string]*Profile `json:"profiles,omitempty"` + + // Global settings + Format string `json:"format,omitempty"` + + // Legacy fields — kept for backward compatibility during migration. + // If present and Profiles is empty, auto-migrate to a "default" profile. + ClientID string `json:"client_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Accounts []string `json:"accounts,omitempty"` + Environment string `json:"environment,omitempty"` +} + +// ActiveProfileConfig returns the active profile, resolving legacy config if needed. +func (c *Config) ActiveProfileConfig() *Profile { + // If we have profiles, use the active one + if len(c.Profiles) > 0 { + name := c.ActiveProfile + if name == "" { + name = "default" + } + if p, ok := c.Profiles[name]; ok { + return p + } + // Active profile doesn't exist — return first available + for _, p := range c.Profiles { + return p + } + } + + // Legacy: no profiles map, use top-level fields + return &Profile{ + ClientID: c.ClientID, + AccountID: c.AccountID, + Accounts: c.Accounts, + Environment: c.Environment, + } +} + +// SetProfile stores a profile and sets it as active. +func (c *Config) SetProfile(name string, p *Profile) { + if c.Profiles == nil { + c.Profiles = make(map[string]*Profile) + } + c.Profiles[name] = p + c.ActiveProfile = name + + // Also set legacy fields so older code that reads them directly still works + c.ClientID = p.ClientID + c.AccountID = p.AccountID + c.Accounts = p.Accounts + c.Environment = p.Environment +} + +// ProfileNames returns sorted profile names. +func (c *Config) ProfileNames() []string { + names := make([]string, 0, len(c.Profiles)) + for k := range c.Profiles { + names = append(names, k) + } + return names +} + +// HasMultipleEnvironments reports whether the configured profiles span more +// than one distinct environment. This is used to decide whether to surface the +// active environment in CLI output — a customer with only prod credentials +// doesn't need to see "Environment: production" on every command. +func (c *Config) HasMultipleEnvironments() bool { + seen := make(map[string]bool) + for _, p := range c.Profiles { + env := p.Environment + if env == "" { + env = "prod" + } + seen[env] = true + if len(seen) > 1 { + return true + } + } + return false +} + +// DefaultPath returns the default config file path. +// Prefers the XDG-compliant ~/.config/band/config.json, but falls back to the +// legacy ~/.band/config.json if it exists and the new path doesn't (auto-migration). +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + newPath := filepath.Join(home, ".config", "band", "config.json") + legacyPath := filepath.Join(home, ".band", "config.json") + + // Use legacy path only if it exists and the new path does not yet exist. + if _, err := os.Stat(newPath); os.IsNotExist(err) { + if _, err := os.Stat(legacyPath); err == nil { + return legacyPath, nil + } + } + + return newPath, nil +} + +// Load reads config from path, overlays env vars, and returns defaults if the +// file does not exist. The default format is "json". +func Load(path string) (*Config, error) { + cfg := &Config{Format: "json"} + + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + if err == nil { + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + } + + // Overlay environment variables on the active profile + p := cfg.ActiveProfileConfig() + if v := os.Getenv("BW_CLIENT_ID"); v != "" { + p.ClientID = v + cfg.ClientID = v + } + if v := os.Getenv("BW_ACCOUNT_ID"); v != "" { + p.AccountID = v + cfg.AccountID = v + } + if v := os.Getenv("BW_FORMAT"); v != "" { + cfg.Format = v + } + if v := os.Getenv("BW_ENVIRONMENT"); v != "" { + p.Environment = v + cfg.Environment = v + } + + return cfg, nil +} + +// Save writes cfg as JSON to path, creating parent directories as needed. +// Directories are created with 0700 permissions; the file is written with 0600. +func Save(path string, cfg *Config) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..efbe492 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,326 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestDefaultPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".config", "band", "config.json") + got, err := DefaultPath() + if err != nil { + t.Fatalf("DefaultPath() error: %v", err) + } + if got != want { + t.Errorf("DefaultPath() = %q, want %q", got, want) + } +} + +func TestLoadDefaults(t *testing.T) { + // Point at a path that doesn't exist — should return defaults + cfg, err := Load("/tmp/band-cli-test-nonexistent/config.json") + if err != nil { + t.Fatalf("Load() on missing file returned error: %v", err) + } + if cfg.Format != "json" { + t.Errorf("default Format = %q, want %q", cfg.Format, "json") + } + if cfg.AccountID != "" || cfg.ClientID != "" { + t.Errorf("expected empty defaults, got %+v", cfg) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + want := &Config{ + ClientID: "my-client-id", + AccountID: "ACC123", + Format: "table", + Environment: "test", + } + + if err := Save(path, want); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Verify file permissions + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat() error: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("file permissions = %o, want 0600", perm) + } + } + + got, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if got.ClientID != want.ClientID { + t.Errorf("ClientID = %q, want %q", got.ClientID, want.ClientID) + } + if got.AccountID != want.AccountID { + t.Errorf("AccountID = %q, want %q", got.AccountID, want.AccountID) + } + if got.Format != want.Format { + t.Errorf("Format = %q, want %q", got.Format, want.Format) + } + if got.Environment != want.Environment { + t.Errorf("Environment = %q, want %q", got.Environment, want.Environment) + } +} + +func TestSaveCreatesNestedDirs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nested", "deep", "config.json") + + cfg := &Config{Format: "json"} + if err := Save(path, cfg); err != nil { + t.Fatalf("Save() with nested dirs error: %v", err) + } + + // Verify parent dir permissions + parent := filepath.Dir(path) + info, err := os.Stat(parent) + if err != nil { + t.Fatalf("Stat() on parent dir error: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0700 { + t.Errorf("dir permissions = %o, want 0700", perm) + } + } +} + +func TestEnvVarOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + base := &Config{ + ClientID: "FROM_FILE", + AccountID: "ACC_FROM_FILE", + Format: "json", + } + if err := Save(path, base); err != nil { + t.Fatalf("Save() error: %v", err) + } + + t.Setenv("BW_ACCOUNT_ID", "FROM_ENV") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.AccountID != "FROM_ENV" { + t.Errorf("AccountID = %q, want %q (env override)", cfg.AccountID, "FROM_ENV") + } + // Other fields should still come from file + if cfg.ClientID != "FROM_FILE" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "FROM_FILE") + } +} + +func TestActiveProfileConfig_Legacy(t *testing.T) { + cfg := &Config{ + ClientID: "legacy-id", + AccountID: "legacy-acct", + } + p := cfg.ActiveProfileConfig() + if p.ClientID != "legacy-id" { + t.Errorf("ClientID = %q, want %q", p.ClientID, "legacy-id") + } + if p.AccountID != "legacy-acct" { + t.Errorf("AccountID = %q, want %q", p.AccountID, "legacy-acct") + } +} + +func TestActiveProfileConfig_WithProfiles(t *testing.T) { + cfg := &Config{ + ActiveProfile: "admin", + Profiles: map[string]*Profile{ + "default": {ClientID: "default-id", AccountID: "default-acct"}, + "admin": {ClientID: "admin-id", AccountID: ""}, + }, + } + p := cfg.ActiveProfileConfig() + if p.ClientID != "admin-id" { + t.Errorf("ClientID = %q, want %q", p.ClientID, "admin-id") + } + if p.AccountID != "" { + t.Errorf("AccountID = %q, want empty", p.AccountID) + } +} + +func TestSetProfile(t *testing.T) { + cfg := &Config{Format: "json"} + p := &Profile{ClientID: "new-id", AccountID: "new-acct", Accounts: []string{"a1", "a2"}} + cfg.SetProfile("test", p) + + if cfg.ActiveProfile != "test" { + t.Errorf("ActiveProfile = %q, want %q", cfg.ActiveProfile, "test") + } + if cfg.Profiles["test"].ClientID != "new-id" { + t.Errorf("profile ClientID = %q, want %q", cfg.Profiles["test"].ClientID, "new-id") + } + // Legacy fields should be synced + if cfg.ClientID != "new-id" { + t.Errorf("legacy ClientID = %q, want %q", cfg.ClientID, "new-id") + } +} + +func TestSetProfile_MultipleProfiles(t *testing.T) { + cfg := &Config{Format: "json"} + cfg.SetProfile("first", &Profile{ClientID: "first-id", AccountID: "first-acct"}) + cfg.SetProfile("second", &Profile{ClientID: "second-id", AccountID: "second-acct"}) + + // Second should be active + if cfg.ActiveProfile != "second" { + t.Errorf("ActiveProfile = %q, want %q", cfg.ActiveProfile, "second") + } + // First should still exist + if cfg.Profiles["first"].ClientID != "first-id" { + t.Errorf("first profile ClientID = %q, want %q", cfg.Profiles["first"].ClientID, "first-id") + } + // Both should be in profiles + if len(cfg.Profiles) != 2 { + t.Errorf("got %d profiles, want 2", len(cfg.Profiles)) + } +} + +func TestProfileSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + cfg := &Config{Format: "json"} + cfg.SetProfile("default", &Profile{ClientID: "def-id", AccountID: "def-acct", Accounts: []string{"a1"}}) + cfg.SetProfile("admin", &Profile{ClientID: "adm-id", Accounts: []string{}}) + cfg.ActiveProfile = "default" // switch back to default + + if err := Save(path, cfg); err != nil { + t.Fatal(err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + + if loaded.ActiveProfile != "default" { + t.Errorf("ActiveProfile = %q, want %q", loaded.ActiveProfile, "default") + } + if len(loaded.Profiles) != 2 { + t.Fatalf("got %d profiles, want 2", len(loaded.Profiles)) + } + if loaded.Profiles["admin"].ClientID != "adm-id" { + t.Errorf("admin ClientID = %q, want %q", loaded.Profiles["admin"].ClientID, "adm-id") + } + if loaded.Profiles["default"].AccountID != "def-acct" { + t.Errorf("default AccountID = %q, want %q", loaded.Profiles["default"].AccountID, "def-acct") + } +} + +func TestHasMultipleEnvironments(t *testing.T) { + tests := []struct { + name string + profiles map[string]*Profile + want bool + }{ + { + name: "no profiles", + profiles: nil, + want: false, + }, + { + name: "single prod profile", + profiles: map[string]*Profile{ + "default": {ClientID: "id1", Environment: ""}, + }, + want: false, + }, + { + name: "single custom env profile", + profiles: map[string]*Profile{ + "default": {ClientID: "id1", Environment: "custom"}, + }, + want: false, + }, + { + name: "two profiles same env", + profiles: map[string]*Profile{ + "a": {ClientID: "id1", Environment: "prod"}, + "b": {ClientID: "id2", Environment: ""}, + }, + want: false, + }, + { + name: "prod and custom", + profiles: map[string]*Profile{ + "default": {ClientID: "id1", Environment: ""}, + "secondary": {ClientID: "id2", Environment: "custom"}, + }, + want: true, + }, + { + name: "test and custom", + profiles: map[string]*Profile{ + "test": {ClientID: "id1", Environment: "test"}, + "custom": {ClientID: "id2", Environment: "custom"}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Profiles: tt.profiles} + if got := cfg.HasMultipleEnvironments(); got != tt.want { + t.Errorf("HasMultipleEnvironments() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAllEnvVarOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + base := &Config{Format: "json"} + if err := Save(path, base); err != nil { + t.Fatalf("Save() error: %v", err) + } + + t.Setenv("BW_CLIENT_ID", "envclientid") + t.Setenv("BW_ACCOUNT_ID", "envaccount") + t.Setenv("BW_FORMAT", "table") + t.Setenv("BW_ENVIRONMENT", "custom") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.ClientID != "envclientid" { + t.Errorf("ClientID = %q, want %q", cfg.ClientID, "envclientid") + } + if cfg.AccountID != "envaccount" { + t.Errorf("AccountID = %q, want %q", cfg.AccountID, "envaccount") + } + if cfg.Format != "table" { + t.Errorf("Format = %q, want %q", cfg.Format, "table") + } + if cfg.Environment != "custom" { + t.Errorf("Environment = %q, want %q", cfg.Environment, "custom") + } +} diff --git a/internal/output/find.go b/internal/output/find.go new file mode 100644 index 0000000..a893da8 --- /dev/null +++ b/internal/output/find.go @@ -0,0 +1,23 @@ +package output + +// FindByName searches a flattened list response for an item where the given +// field matches name. Returns the item if found, nil otherwise. +// Handles both array results and single-object results from the XML-to-JSON parser. +func FindByName(data any, field, name string) any { + flat := FlattenResponse(data) + switch v := flat.(type) { + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if val, ok := m[field].(string); ok && val == name { + return item + } + } + } + case map[string]any: + if val, ok := v[field].(string); ok && val == name { + return v + } + } + return nil +} diff --git a/internal/output/flatten.go b/internal/output/flatten.go new file mode 100644 index 0000000..6510aea --- /dev/null +++ b/internal/output/flatten.go @@ -0,0 +1,96 @@ +package output + +import "strings" + +// NormalizeToArray ensures list command output is always an array. +// The XML-to-JSON parser returns a single object when there's one result, +// which causes silent agent failures. This wraps a lone map in a slice. +func NormalizeToArray(data interface{}) interface{} { + switch v := data.(type) { + case []interface{}: + return v + case map[string]interface{}: + return []interface{}{v} + default: + return data + } +} + +// metadataKeys are keys that indicate a wrapper object, not a resource. +var metadataKeys = map[string]bool{ + "Count": true, "TotalCount": true, "ResultCount": true, + "Links": true, "first": true, "last": true, "next": true, "prev": true, +} + +// FlattenResponse recursively unwraps single-key objects until it reaches +// an array or a meaningful multi-key object. For wrapper objects (metadata + one array), +// it extracts the array. This converts structures like +// {"TNs":{"TelephoneNumbers":{"Count":"5","TelephoneNumber":[...]}}} into just [...]. +func FlattenResponse(data interface{}) interface{} { + m, ok := data.(map[string]interface{}) + if !ok { + return data + } + + // Single-key wrapper — unwrap and recurse. + if len(m) == 1 { + for _, v := range m { + return FlattenResponse(v) + } + } + + // JSON API envelope: {data: ..., links: [...], errors: [...], page: {...}} + // Extract just the "data" field if the other keys are standard envelope keys. + if d, hasData := m["data"]; hasData { + isEnvelope := true + for k := range m { + if k != "data" && k != "links" && k != "errors" && k != "page" { + isEnvelope = false + break + } + } + if isEnvelope { + return FlattenResponse(d) + } + } + + // Recurse into values first to flatten nested wrappers. + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = FlattenResponse(v) + } + + // Check if this looks like a list wrapper: one array + remaining keys are + // all metadata (Count, Links, etc.) with string values. + if arr, ok := extractListArray(result); ok { + return arr + } + + return result +} + +// extractListArray checks if a map is a "list wrapper" — one array value +// and all other keys are known metadata keys with string values. +func extractListArray(m map[string]interface{}) (interface{}, bool) { + var arrayVal interface{} + arrayCount := 0 + + for k, v := range m { + switch v.(type) { + case []interface{}: + arrayVal = v + arrayCount++ + case string: + if !metadataKeys[k] && !strings.Contains(k, "Count") && !strings.Contains(k, "Link") { + return nil, false // non-metadata string field → this is a resource, not a wrapper + } + default: + return nil, false // complex non-array value → not a simple wrapper + } + } + + if arrayCount == 1 { + return arrayVal, true + } + return nil, false +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..229a582 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,233 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + + "github.com/olekukonko/tablewriter" +) + +// Print writes data to w in the specified format ("json" or "table"). +func Print(w io.Writer, format string, data interface{}) error { + switch format { + case "json": + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(data) + + case "table": + return printTable(w, data) + + default: + return fmt.Errorf("unknown output format %q: supported formats are json, table", format) + } +} + +// printTable renders data as a table, handling the various types that come +// out of JSON deserialization as well as pre-formatted []map[string]string. +func printTable(w io.Writer, data interface{}) error { + if data == nil { + return nil + } + + switch v := data.(type) { + + // Fast path: caller already prepared []map[string]string rows. + case []map[string]string: + if len(v) == 0 { + return nil + } + columns := sortedKeys(v[0]) + PrintTable(w, columns, v) + return nil + + // Array of objects (the common JSON-deserialized case). + case []interface{}: + if len(v) == 0 { + return nil + } + // Check the first element to decide rendering strategy. + switch first := v[0].(type) { + case map[string]interface{}: + columns := sortedKeysIface(first) + rows := make([]map[string]string, 0, len(v)) + for _, item := range v { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, stringifyMap(m, columns)) + } + if len(rows) == 0 { + return nil + } + PrintTable(w, columns, rows) + return nil + default: + // Array of primitives — single-column table. + columns := []string{"Value"} + rows := make([]map[string]string, 0, len(v)) + for _, item := range v { + rows = append(rows, map[string]string{"Value": fmt.Sprintf("%v", item)}) + } + PrintTable(w, columns, rows) + return nil + } + + // Single object — render as a two-column key/value table. + case map[string]interface{}: + if len(v) == 0 { + return nil + } + columns := []string{"Field", "Value"} + keys := sortedKeysIface(v) + rows := make([]map[string]string, 0, len(keys)) + for _, k := range keys { + rows = append(rows, map[string]string{ + "Field": k, + "Value": stringifyValue(v[k]), + }) + } + PrintTable(w, columns, rows) + return nil + + // Bare string or other primitive — just print it. + case string: + fmt.Fprintln(w, v) + return nil + default: + fmt.Fprintf(w, "%v\n", v) + return nil + } +} + +// stringifyMap converts a map[string]interface{} to map[string]string using +// the provided column order. Nested objects/arrays are rendered as truncated JSON. +func stringifyMap(m map[string]interface{}, columns []string) map[string]string { + out := make(map[string]string, len(columns)) + for _, k := range columns { + out[k] = stringifyValue(m[k]) + } + return out +} + +// stringifyValue converts an arbitrary value to a table-friendly string. +// Maps and slices are rendered as compact JSON, truncated to 50 characters. +func stringifyValue(val interface{}) string { + if val == nil { + return "" + } + switch v := val.(type) { + case map[string]interface{}, []interface{}: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + s := string(b) + if len(s) > 50 { + return s[:47] + "..." + } + return s + default: + return fmt.Sprintf("%v", v) + } +} + +// sortedKeys returns the keys of a map[string]string in sorted order. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// sortedKeysIface returns the keys of a map[string]interface{} in sorted order. +func sortedKeysIface(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// PrintTable writes a formatted table to w with the given columns and rows. +func PrintTable(w io.Writer, columns []string, rows []map[string]string) { + table := tablewriter.NewWriter(w) + table.SetHeader(columns) + table.SetBorder(false) + table.SetAutoWrapText(false) + + for _, row := range rows { + record := make([]string, len(columns)) + for i, col := range columns { + record[i] = row[col] + } + table.Append(record) + } + + table.Render() +} + +// Stdout prints data to os.Stdout in the specified format. +func Stdout(format string, data interface{}) error { + return Print(os.Stdout, format, data) +} + +// StdoutPlain prints data to os.Stdout after flattening it to a simplified +// structure. Intended for script and agent use where deep nesting is noise. +func StdoutPlain(data interface{}) error { + flat := FlattenResponse(data) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(flat) +} + +// StdoutAuto prints data to os.Stdout. If plain is true it flattens first; +// otherwise it uses the specified format string. Table format also flattens +// to unwrap API response wrappers before rendering. This is the preferred +// helper for command RunE functions. +func StdoutAuto(format string, plain bool, data interface{}) error { + if plain { + return StdoutPlain(data) + } + if format == "table" { + return Stdout(format, FlattenResponse(data)) + } + return Stdout(format, data) +} + +// FlattenAndNormalize flattens a raw API response and normalizes it to an array. +// Convenience wrapper for callers that need the processed value (e.g. polling loops). +func FlattenAndNormalize(data interface{}) interface{} { + return NormalizeToArray(FlattenResponse(data)) +} + +// StdoutPlainList is like StdoutAuto for list commands — it always normalizes +// the result to an array when plain is true, preventing single-item ambiguity. +// Table format also flattens and normalizes to unwrap API wrappers. +func StdoutPlainList(format string, plain bool, data interface{}) error { + if plain { + flat := FlattenResponse(data) + normalized := NormalizeToArray(flat) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(normalized) + } + if format == "table" { + flat := FlattenResponse(data) + normalized := NormalizeToArray(flat) + return Stdout(format, normalized) + } + return Stdout(format, data) +} + +// Error prints a formatted error message to os.Stderr. +func Error(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..a4b4421 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,407 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestJSON(t *testing.T) { + type data struct { + ID string `json:"id"` + Name string `json:"name"` + } + + var buf bytes.Buffer + err := Print(&buf, "json", data{ID: "abc123", Name: "test account"}) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "abc123") { + t.Errorf("output missing id field, got: %s", out) + } + if !strings.Contains(out, "test account") { + t.Errorf("output missing name field, got: %s", out) + } +} + +func TestTable(t *testing.T) { + var buf bytes.Buffer + + columns := []string{"ID", "Name", "Status"} + rows := []map[string]string{ + {"ID": "1", "Name": "Alice", "Status": "active"}, + {"ID": "2", "Name": "Bob", "Status": "inactive"}, + } + + PrintTable(&buf, columns, rows) + + out := buf.String() + if !strings.Contains(out, "Alice") { + t.Errorf("table output missing Alice, got: %s", out) + } + if !strings.Contains(out, "Bob") { + t.Errorf("table output missing Bob, got: %s", out) + } + if !strings.Contains(out, "active") { + t.Errorf("table output missing 'active', got: %s", out) + } +} + +func TestPrint_TableFormat(t *testing.T) { + rows := []map[string]string{ + {"ID": "10", "Name": "Widget"}, + } + + var buf bytes.Buffer + err := Print(&buf, "table", rows) + if err != nil { + t.Fatalf("Print() table error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Widget") { + t.Errorf("table output missing Widget, got: %s", out) + } +} + +func TestPrint_UnknownFormat(t *testing.T) { + var buf bytes.Buffer + err := Print(&buf, "xml", struct{}{}) + if err == nil { + t.Error("expected error for unknown format, got nil") + } +} + +func TestError(t *testing.T) { + // Just verify Error() doesn't panic and produces output. + // We can't easily capture stderr in a unit test without redirecting os.Stderr, + // so we just call it and make sure it runs without panicking. + Error("something went wrong: %s", "details") +} + +func TestFlattenResponse_SingleKeyUnwrap(t *testing.T) { + input := map[string]interface{}{ + "TNs": map[string]interface{}{ + "TelephoneNumbers": map[string]interface{}{ + "TelephoneNumber": []interface{}{"9195551234", "9195555678"}, + }, + }, + } + result := FlattenResponse(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T: %v", result, result) + } + if len(arr) != 2 { + t.Errorf("expected 2 numbers, got %d", len(arr)) + } +} + +func TestFlattenResponse_MultiKeyPreserved(t *testing.T) { + input := map[string]interface{}{ + "ID": "123", + "Name": "test", + } + result := FlattenResponse(input) + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("expected map[string]interface{}, got %T", result) + } + if m["ID"] != "123" || m["Name"] != "test" { + t.Errorf("multi-key object modified unexpectedly: %v", m) + } +} + +func TestFlattenResponse_AlreadyArray(t *testing.T) { + input := []interface{}{"a", "b", "c"} + result := FlattenResponse(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", result) + } + if len(arr) != 3 { + t.Errorf("expected 3 items, got %d", len(arr)) + } +} + +func TestFlattenResponse_SitesPattern(t *testing.T) { + input := map[string]interface{}{ + "SitesResponse": map[string]interface{}{ + "Sites": map[string]interface{}{ + "Site": []interface{}{ + map[string]interface{}{"Name": "site1"}, + map[string]interface{}{"Name": "site2"}, + }, + }, + }, + } + result := FlattenResponse(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T: %v", result, result) + } + if len(arr) != 2 { + t.Errorf("expected 2 sites, got %d", len(arr)) + } +} + +func TestFlattenResponse_JSONEnvelope(t *testing.T) { + // JSON API responses use {data: [...], links: [...], errors: [], page: {...}} + input := map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{"name": "VCP 1", "id": "abc"}, + map[string]interface{}{"name": "VCP 2", "id": "def"}, + }, + "links": []interface{}{map[string]interface{}{"rel": "self"}}, + "errors": []interface{}{}, + "page": map[string]interface{}{"pageSize": float64(500)}, + } + result := FlattenResponse(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T: %v", result, result) + } + if len(arr) != 2 { + t.Errorf("expected 2 items, got %d", len(arr)) + } +} + +func TestFlattenResponse_JSONEnvelope_SingleObject(t *testing.T) { + // Single resource get: {data: {...}, links: [...], errors: []} + input := map[string]interface{}{ + "data": map[string]interface{}{"name": "VCP 1", "id": "abc"}, + "links": []interface{}{}, + "errors": []interface{}{}, + } + result := FlattenResponse(input) + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("expected map, got %T: %v", result, result) + } + if m["name"] != "VCP 1" { + t.Errorf("name = %v, want VCP 1", m["name"]) + } +} + +func TestNormalizeToArray_AlreadyArray(t *testing.T) { + input := []interface{}{map[string]interface{}{"id": "1"}} + result := NormalizeToArray(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", result) + } + if len(arr) != 1 { + t.Errorf("expected 1 item, got %d", len(arr)) + } +} + +func TestNormalizeToArray_SingleObject(t *testing.T) { + input := map[string]interface{}{"id": "1", "name": "test"} + result := NormalizeToArray(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", result) + } + if len(arr) != 1 { + t.Errorf("expected 1 item, got %d", len(arr)) + } +} + +func TestNormalizeToArray_String(t *testing.T) { + result := NormalizeToArray("hello") + if result != "hello" { + t.Errorf("expected string passthrough, got %v", result) + } +} + +// --- Table rendering of JSON-deserialized types --- + +func TestPrint_Table_ArrayOfObjects(t *testing.T) { + // This is the core bug fix: []interface{} of map[string]interface{} should + // render as a multi-column table, not silently produce nothing. + data := []interface{}{ + map[string]interface{}{"id": "abc", "name": "Alice", "status": "active"}, + map[string]interface{}{"id": "def", "name": "Bob", "status": "inactive"}, + } + + var buf bytes.Buffer + err := Print(&buf, "table", data) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + out := buf.String() + for _, want := range []string{"Alice", "Bob", "abc", "def", "active", "inactive"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } + // Columns should be sorted alphabetically: id, name, status + idIdx := strings.Index(out, "ID") + nameIdx := strings.Index(out, "NAME") + statusIdx := strings.Index(out, "STATUS") + if idIdx == -1 || nameIdx == -1 || statusIdx == -1 { + t.Fatalf("missing header(s) in output:\n%s", out) + } +} + +func TestPrint_Table_SingleObject(t *testing.T) { + data := map[string]interface{}{ + "id": "abc123", + "name": "My Account", + } + + var buf bytes.Buffer + err := Print(&buf, "table", data) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + out := buf.String() + // Should render as a key-value table. Headers are uppercased by tablewriter. + for _, want := range []string{"FIELD", "VALUE", "id", "abc123", "name", "My Account"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } +} + +func TestPrint_Table_ArrayOfPrimitives(t *testing.T) { + data := []interface{}{"+19195551234", "+19195555678", "+19190001111"} + + var buf bytes.Buffer + err := Print(&buf, "table", data) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + out := buf.String() + for _, want := range []string{"+19195551234", "+19195555678", "+19190001111"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } +} + +func TestPrint_Table_NestedValuesTruncated(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "id": "abc", + "config": map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "a-somewhat-long-value-that-will-push-us-past-fifty", + }, + }, + } + + var buf bytes.Buffer + err := Print(&buf, "table", data) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "abc") { + t.Errorf("table output missing 'abc', got:\n%s", out) + } + // The nested config should be truncated with "..." + if !strings.Contains(out, "...") { + t.Errorf("expected truncated nested JSON with '...', got:\n%s", out) + } +} + +func TestPrint_Table_String(t *testing.T) { + var buf bytes.Buffer + err := Print(&buf, "table", "hello world") + if err != nil { + t.Fatalf("Print() error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "hello world") { + t.Errorf("expected string output, got: %s", out) + } +} + +func TestPrint_Table_Nil(t *testing.T) { + var buf bytes.Buffer + err := Print(&buf, "table", nil) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected no output for nil, got: %s", buf.String()) + } +} + +func TestPrint_Table_EmptyArray(t *testing.T) { + var buf bytes.Buffer + err := Print(&buf, "table", []interface{}{}) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected no output for empty array, got: %s", buf.String()) + } +} + +func TestPrint_Table_EmptyMap(t *testing.T) { + var buf bytes.Buffer + err := Print(&buf, "table", map[string]interface{}{}) + if err != nil { + t.Fatalf("Print() error: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected no output for empty map, got: %s", buf.String()) + } +} + +func TestStringifyValue_NilReturnsEmpty(t *testing.T) { + if got := stringifyValue(nil); got != "" { + t.Errorf("stringifyValue(nil) = %q, want empty string", got) + } +} + +func TestStringifyValue_ShortJSON(t *testing.T) { + val := map[string]interface{}{"a": "b"} + got := stringifyValue(val) + if got != `{"a":"b"}` { + t.Errorf("stringifyValue short map = %q, want %q", got, `{"a":"b"}`) + } +} + +func TestStringifyValue_TruncatesLongJSON(t *testing.T) { + val := map[string]interface{}{ + "longKey": "this is a really long value that will definitely exceed fifty characters total", + } + got := stringifyValue(val) + if len(got) > 50 { + t.Errorf("stringifyValue should truncate to 50 chars, got %d: %q", len(got), got) + } + if !strings.HasSuffix(got, "...") { + t.Errorf("truncated value should end with '...', got: %q", got) + } +} + +func TestFlattenResponse_NumberList(t *testing.T) { + // Real-world number list response shape + input := map[string]interface{}{ + "TNs": map[string]interface{}{ + "TelephoneNumbers": map[string]interface{}{ + "Count": "3", + "TelephoneNumber": []interface{}{"+19191234567", "+19197654321", "+19190001111"}, + }, + "TotalCount": "3", + "Links": map[string]interface{}{ + "first": "somelink", + }, + }, + } + result := FlattenResponse(input) + arr, ok := result.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T: %v", result, result) + } + if len(arr) != 3 { + t.Errorf("expected 3 numbers, got %d", len(arr)) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..73aeb31 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,51 @@ +package ui + +import ( + "fmt" + "os" + "time" + + "github.com/briandowns/spinner" + "github.com/fatih/color" +) + +var ( + Success = color.New(color.FgGreen).SprintFunc() + Error = color.New(color.FgRed).SprintFunc() + Warn = color.New(color.FgYellow).SprintFunc() + Muted = color.New(color.Faint).SprintFunc() + Bold = color.New(color.Bold).SprintFunc() + ID = color.New(color.FgCyan).SprintFunc() +) + +// Successf prints a green success message to stderr +func Successf(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, "%s %s\n", Success("✓"), fmt.Sprintf(format, a...)) +} + +// Errorf prints a red error message to stderr +func Errorf(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, "%s %s\n", Error("✗"), fmt.Sprintf(format, a...)) +} + +// Warnf prints a yellow warning message to stderr +func Warnf(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, "%s %s\n", Warn("⚠"), fmt.Sprintf(format, a...)) +} + +// Infof prints a muted info message to stderr +func Infof(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, " %s\n", fmt.Sprintf(format, a...)) +} + +// Headerf prints a bold header to stderr +func Headerf(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, "%s\n", Bold(fmt.Sprintf(format, a...))) +} + +// NewSpinner creates a spinner with a message. Call .Start() and .Stop(). +func NewSpinner(msg string) *spinner.Spinner { + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(os.Stderr)) + s.Suffix = " " + msg + return s +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..5301f5c --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,51 @@ +package ui + +import ( + "os" + "strings" + "testing" +) + +func TestNewSpinner(t *testing.T) { + s := NewSpinner("Loading...") + if s == nil { + t.Fatal("NewSpinner returned nil") + } + // Spinner should write to stderr, not stdout + if s.Writer != os.Stderr { + t.Error("spinner writer should be os.Stderr") + } + if !strings.Contains(s.Suffix, "Loading...") { + t.Errorf("spinner suffix = %q, want it to contain 'Loading...'", s.Suffix) + } +} + +func TestColorFunctions(t *testing.T) { + // These return SprintFunc closures — verify they produce non-empty output + // and don't panic. + tests := []struct { + name string + fn func(a ...interface{}) string + }{ + {"Success", Success}, + {"Error", Error}, + {"Warn", Warn}, + {"Muted", Muted}, + {"Bold", Bold}, + {"ID", ID}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.fn("test") + if got == "" { + t.Errorf("%s(\"test\") returned empty string", tc.name) + } + // The raw text should be present somewhere in the output + // (possibly wrapped with ANSI codes) + if !strings.Contains(got, "test") { + t.Errorf("%s(\"test\") = %q, doesn't contain 'test'", tc.name, got) + } + }) + } +} diff --git a/internal/version/check.go b/internal/version/check.go new file mode 100644 index 0000000..5045a50 --- /dev/null +++ b/internal/version/check.go @@ -0,0 +1,195 @@ +package version + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // checkInterval is how often we check for a new version. + checkInterval = 24 * time.Hour + + // releaseURL is the GitHub API endpoint for the latest release. + releaseURL = "https://api.github.com/repos/Bandwidth/cli/releases/latest" +) + +// stateFile tracks the last check time and latest known version. +type stateFile struct { + LastCheck time.Time `json:"last_check"` + LatestVersion string `json:"latest_version"` +} + +// CheckResult is returned when a newer version is available. +type CheckResult struct { + Current string + Latest string +} + +// Check compares the running version against the latest GitHub release. +// Returns nil if the version is current, the check was performed recently, +// or anything goes wrong (version checks should never block the user). +func Check(currentVersion string) *CheckResult { + // Never check for dev builds + if currentVersion == "dev" { + return nil + } + + // Respect BW_NO_UPDATE_NOTIFIER for CI/scripts + if os.Getenv("BW_NO_UPDATE_NOTIFIER") != "" { + return nil + } + + stateDir, err := stateDir() + if err != nil { + return nil + } + statePath := filepath.Join(stateDir, "update-check.json") + + // Read cached state + state := loadState(statePath) + + // If we checked recently, use the cached result + if time.Since(state.LastCheck) < checkInterval { + if state.LatestVersion != "" && isNewer(currentVersion, state.LatestVersion) { + return &CheckResult{Current: currentVersion, Latest: state.LatestVersion} + } + return nil + } + + // Fetch latest version from GitHub (with a short timeout) + latest, err := fetchLatestVersion() + if err != nil { + return nil + } + + // Cache the result + state.LastCheck = time.Now() + state.LatestVersion = latest + saveState(statePath, state) + + if isNewer(currentVersion, latest) { + return &CheckResult{Current: currentVersion, Latest: latest} + } + return nil +} + +// NoticeMessage returns a user-friendly upgrade notice. +func (r *CheckResult) NoticeMessage() string { + return fmt.Sprintf("A new version of band is available: %s → %s\nUpdate with: brew upgrade band or go install github.com/Bandwidth/cli/cmd/band@latest", r.Current, r.Latest) +} + +// fetchLatestVersion hits the GitHub releases API and returns the tag name. +func fetchLatestVersion() (string, error) { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(releaseURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github API returned %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + return strings.TrimPrefix(release.TagName, "v"), nil +} + +// isNewer returns true if latest is a newer version than current. +// Simple string comparison after normalizing — works for semver. +func isNewer(current, latest string) bool { + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + if current == latest { + return false + } + return compareSemver(current, latest) < 0 +} + +// compareSemver compares two semver strings. Returns -1, 0, or 1. +// Handles pre-release tags: 0.0.3-beta < 0.0.3. +func compareSemver(a, b string) int { + aParts, aPre := splitPrerelease(a) + bParts, bPre := splitPrerelease(b) + + // Compare major.minor.patch + for i := 0; i < 3; i++ { + av, bv := 0, 0 + if i < len(aParts) { + fmt.Sscanf(aParts[i], "%d", &av) + } + if i < len(bParts) { + fmt.Sscanf(bParts[i], "%d", &bv) + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + + // Same version numbers — pre-release is lower than release + if aPre != "" && bPre == "" { + return -1 + } + if aPre == "" && bPre != "" { + return 1 + } + // Both have pre-release: lexicographic + if aPre < bPre { + return -1 + } + if aPre > bPre { + return 1 + } + return 0 +} + +// splitPrerelease splits "1.2.3-beta" into (["1","2","3"], "beta"). +func splitPrerelease(v string) ([]string, string) { + pre := "" + if idx := strings.IndexByte(v, '-'); idx >= 0 { + pre = v[idx+1:] + v = v[:idx] + } + return strings.Split(v, "."), pre +} + +// stateDir returns the directory for storing version check state. +func stateDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".config", "band") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +func loadState(path string) stateFile { + data, err := os.ReadFile(path) + if err != nil { + return stateFile{} + } + var s stateFile + json.Unmarshal(data, &s) + return s +} + +func saveState(path string, s stateFile) { + data, _ := json.Marshal(s) + os.WriteFile(path, data, 0o644) +} diff --git a/internal/version/check_test.go b/internal/version/check_test.go new file mode 100644 index 0000000..ad92fb9 --- /dev/null +++ b/internal/version/check_test.go @@ -0,0 +1,56 @@ +package version + +import "testing" + +func TestIsNewer(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + {"0.0.3", "0.0.4", true}, + {"0.0.3", "0.0.3", false}, + {"0.0.4", "0.0.3", false}, + {"0.1.0", "0.2.0", true}, + {"1.0.0", "2.0.0", true}, + {"0.0.3-beta", "0.0.3", true}, + {"0.0.3-beta", "0.0.3-beta", false}, + {"0.0.3", "0.0.3-beta", false}, + {"0.0.3-alpha", "0.0.3-beta", true}, + {"v0.0.3", "v0.0.4", true}, + {"v0.0.3", "0.0.4", true}, + } + + for _, tt := range tests { + t.Run(tt.current+"_vs_"+tt.latest, func(t *testing.T) { + got := isNewer(tt.current, tt.latest) + if got != tt.want { + t.Errorf("isNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + }) + } +} + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"0.0.1", "0.0.2", -1}, + {"0.0.2", "0.0.1", 1}, + {"1.0.0", "1.0.0", 0}, + {"0.0.3-beta", "0.0.3", -1}, + {"0.0.3", "0.0.3-beta", 1}, + {"1.2.3", "1.2.4", -1}, + {"2.0.0", "1.9.9", 1}, + } + + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + got := compareSemver(tt.a, tt.b) + if got != tt.want { + t.Errorf("compareSemver(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +}