Merge remote-tracking branch 'origin/main' into feature/mailbox-fair-use-allowance

This commit is contained in:
Matthew Meszaros
2026-09-04 21:25:53 -07:00
70 changed files with 11208 additions and 18 deletions
+24 -1
View File
@@ -35,6 +35,7 @@ jobs:
make: ${{ steps.filter.outputs.make }}
ios: ${{ steps.filter.outputs.ios }}
installer: ${{ steps.filter.outputs.installer }}
cli-installer: ${{ steps.filter.outputs.cli-installer }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -75,6 +76,12 @@ jobs:
- 'site/public/install.sh'
- 'site/public/install.sh.sha256'
- 'scripts/check-installer.sh'
cli-installer:
- 'site/public/cli.sh'
- 'site/public/cli.sh.sha256'
- 'site/public/cli.ps1'
- 'scripts/check-cli-installer.sh'
- 'scripts/build-cli.sh'
migrations-ci:
name: Migrations
@@ -249,6 +256,22 @@ jobs:
- name: Check the installer
run: ./scripts/check-installer.sh
cli-installer-ci:
name: CLI Installer CI
needs: changes
if: needs.changes.outputs.cli-installer == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# dash is what /bin/sh is on Debian and Ubuntu, which is what most people
# will pipe this into. Ubuntu runners already have shellcheck and pwsh.
- name: Install dash
run: sudo apt-get update && sudo apt-get install -y dash
- name: Check the CLI installer
run: ./scripts/check-cli-installer.sh
make-ci:
name: Make App CI
needs: changes
@@ -418,7 +441,7 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-latest
needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, forms-ci, installer-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, forms-ci, installer-ci, cli-installer-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
if: always()
steps:
- name: Check CI status
+97 -3
View File
@@ -36,7 +36,7 @@ jobs:
strategy:
fail-fast: false
matrix:
service: [backend, consumer, worker, forms, updater]
service: [backend, consumer, worker, forms, updater, cli]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -214,9 +214,46 @@ jobs:
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *)
# The `warmbly` CLI is a plain static binary, so it cross-compiles for every
# platform on one runner.
#
# Assets are named WITHOUT the version, so
# releases/latest/download/warmbly_linux_amd64.tar.gz always resolves. That is
# what lets the install script find the newest build with no GitHub API call,
# which matters because the unauthenticated API is rate limited and a curl
# installer that fails on a busy CI runner is not an installer.
build-cli:
name: Build CLI
needs: validate-tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Cross-compile and package
env:
VERSION: ${{ github.ref_name }}
COMMIT: ${{ github.sha }}
BUILT_AT: ${{ github.event.head_commit.timestamp }}
run: |
set -euo pipefail
./scripts/build-cli.sh dist
- name: Upload
uses: actions/upload-artifact@v4
with:
name: warmbly-cli
path: dist/
retention-days: 1
create-release:
name: Create GitHub Release
needs: [validate-tag, build-go, build-frontend, merge-native]
needs: [validate-tag, build-go, build-frontend, merge-native, build-cli]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -228,6 +265,12 @@ jobs:
with:
fetch-depth: 0
- name: Download the CLI binaries
uses: actions/download-artifact@v4
with:
name: warmbly-cli
path: /tmp/cli
# The installer verifies what it pulled against this file, so it is what
# makes "curl | sh" checkable after the fact rather than only before it.
# One line per service, because the thing that reads it is a POSIX shell.
@@ -284,6 +327,18 @@ jobs:
Add `--wizard` to be asked where each store lives, what is kept and
for how long, and how it is backed up.
## The warmbly CLI
```
curl -fsSL https://warmbly.com/cli.sh | sh # macOS, Linux
irm https://warmbly.com/cli.ps1 | iex # Windows
brew install warmbly/tap/warmbly # Homebrew
scoop install warmbly # Scoop
```
Or take an archive below and unpack it yourself; `checksums.txt`
verifies every one of them. Already installed? `warmbly upgrade`.
`images.json` below lists the manifest digest of every image in this
release. The installer checks what it pulled against it, and you can
too:
@@ -307,6 +362,7 @@ jobs:
| Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` |
| Forms | `${{ env.IMAGE_PREFIX }}/forms:${{ github.ref_name }}` |
| Updater | `${{ env.IMAGE_PREFIX }}/updater:${{ github.ref_name }}` |
| CLI | `${{ env.IMAGE_PREFIX }}/cli:${{ github.ref_name }}` |
## Deployment
@@ -314,12 +370,50 @@ jobs:
EOF
} > /tmp/release-body.md
# The formula and manifest are generated with the archives, so their
# checksums can never drift from what they describe. Pushing them is
# skipped, loudly, when the tap token is not configured: a release must
# not fail because a downstream package repo is not set up yet.
- name: Publish the Homebrew formula and Scoop manifest
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -z "${TAP_TOKEN:-}" ]; then
echo "HOMEBREW_TAP_TOKEN is not set; skipping the tap push."
echo "The formula and manifest are still attached to the release."
exit 0
fi
# A prerelease must never become the default `brew install`.
case "$TAG" in
*-*) echo "$TAG is a prerelease; not updating the taps."; exit 0 ;;
esac
git config --global user.name "warmbly-release"
git config --global user.email "release@warmbly.com"
git clone --depth 1 \
"https://x-access-token:${TAP_TOKEN}@github.com/warmbly/homebrew-tap.git" /tmp/tap
mkdir -p /tmp/tap/Formula /tmp/tap/bucket
cp /tmp/cli/warmbly.rb /tmp/tap/Formula/warmbly.rb
cp /tmp/cli/warmbly.json /tmp/tap/bucket/warmbly.json
cd /tmp/tap
git add Formula/warmbly.rb bucket/warmbly.json
if git diff --cached --quiet; then
echo "the tap already describes $TAG"
else
git commit -m "warmbly $TAG"
git push
echo "pushed warmbly $TAG to the tap"
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body_path: /tmp/release-body.md
files: /tmp/images.json
files: |
/tmp/images.json
/tmp/cli/*
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
+5
View File
@@ -15,6 +15,7 @@
/migrate
/updater
/warmblyctl
/warmbly
# Test binary, built with `go test -c`
*.test
@@ -28,6 +29,10 @@ profile.cov
# Dependency directories (remove the comment below to include it)
vendor/
# Local CLI builds (make warmbly / make warmbly-dist)
/bin/
/dist/
# Go workspace file
go.work
go.work.sum
+24 -3
View File
@@ -135,6 +135,25 @@ shellcheck, `--help`, `--demo`, `--print-env`, a compose file per answer shape,
a pty width regression test, and the checksum). `make installer-demo` is how
you see a UI change without installing anything.
`site/public/cli.sh` is the second published script, served at
`https://warmbly.com/cli.sh`, and it installs the `warmbly` CLI rather than an
instance. Every rule above applies to it, plus two of its own:
- **It verifies what it downloads.** The release publishes `checksums.txt`
next to the archives, and a mismatch installs nothing rather than warning.
Never weaken that to a warning
- **Release assets are named without the version**, so
`releases/latest/download/warmbly_<os>_<arch>.tar.gz` resolves with no
GitHub API call. The unauthenticated API is rate limited per IP, which is
what breaks a curl installer on a shared runner. `scripts/build-cli.sh` and
the platform list in `cli.sh` have to agree; `make cli-check` fails when they
do not
`make cli-check` runs the whole thing (POSIX parse, shellcheck, `--help`,
`--dry-run`, a real install from a local mirror, checksum tampering, uninstall,
the PowerShell parse and the checksum), and `make cli-sha` regenerates the
checksum after any edit. `site/public/cli.ps1` is the Windows half.
### Verification: what to run, what to skip
Keep the loop fast. The signals that matter are formatting, lint, and typecheck — not local builds or browser automation.
@@ -148,7 +167,8 @@ For frontend changes, run `pnpm typecheck` and `pnpm lint` in any tree you touch
For a change to `site/public/install.sh`, run `make installer-check`; it is the
same script CI runs and it regenerates nothing, so a stale checksum fails there
exactly as it will in CI.
exactly as it will in CI. For `site/public/cli.sh` or `cli.ps1`, the equivalent
is `make cli-check` (and `make cli-sha` after any edit).
Do not:
@@ -257,17 +277,18 @@ API keys with the `REALTIME_SUBSCRIBE` permission (bit 11) can connect to the sa
- `cmd/backend`: API and business orchestration
- `cmd/consumer`: consumes Kafka events and updates platform state
- `cmd/worker`: execution worker for send/sync operations
- `cmd/cli`: the `warmbly` CLI, the customer-facing one. A signed-in, multi-host client of the public REST API (`internal/cli/*` holds its config, HTTP client and renderers). It never serves HTTP and never touches Postgres; `cmd/warmblyctl` is the operator's CLI and keeps the database half. The directory is `cli` and the binary is `warmbly`, so every build target names its output explicitly (`-o warmbly`), and `go install` needs the rename documented in `docs/content/docs/api/cli.mdx`
- `cmd/forms`: the public face of hosted lead-capture forms (`internal/formserver`): serves the built `forms/` app, per-form page shells (with the per-form embed CSP), the embed loader and public submissions on their own origin (`FORMS_DOMAIN`). No database; the backend's internal API is its only dependency, like the tracking service
- `forms/`: the public form page app (React + TanStack Router/Query/Form, Vite CSR build). Renders a published form from the same-origin `/api/forms/:publicID`, submits to `/api/forms/:publicID/submit`; the Go forms service hosts the build
- `tracking/`: open and click tracking service
- `realtime/`: websocket fanout service
- `web/`: in-product frontend (dashboard). Customer-facing only: it holds no platform-admin screens, and operator tooling must not be added back here
- `admin/`: platform admin panel (:5174), the single operator surface. Workers, users, orgs, warmup, campaigns, analytics, audit. Every route sits behind `RequireAdmin` and the backend's `RequireAdminPermission` gates
- `site/`: public marketing site (Astro 5 + Tailwind v4). `site/public/install.sh` is the self-host installer served at warmbly.com/install.sh, with its checksum next to it; see the rules above before touching it
- `site/`: public marketing site (Astro 5 + Tailwind v4). `site/public/install.sh` is the self-host installer served at warmbly.com/install.sh and `site/public/cli.sh` is the CLI installer served at warmbly.com/cli.sh (with `cli.ps1` for Windows), each with its checksum next to it; see the rules above before touching either
- `deploy/`: production deploy manifests, infrastructure, and runtime config
- `docs/`: documentation site (docs.warmbly.com); product guides, API reference, and self-hosting/engineering docs under `content/docs/development/`
- `scripts/`: one-off tooling (codegen, migrations, installer checks, local dev utilities)
- `skills/`: agent playbooks shipped with the repo (`warmbly-api` for the product, `warmbly-ops` for instance administration, `warmbly-install` for standing an instance up and moving it). A command an operator can run is not usable by an agent until it is in one of these
- `skills/`: agent playbooks shipped with the repo (`warmbly-cli` for the `warmbly` CLI, `warmbly-api` for the same product surface through `warmblyctl`, `warmbly-ops` for instance administration, `warmbly-install` for standing an instance up and moving it). A command an operator can run is not usable by an agent until it is in one of these
## Worker Topology
+28 -1
View File
@@ -42,7 +42,7 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
restart restart-go restart-all infra infra-down app app-down app-logs \
backend forms forms-web consumer worker run dev tracking realtime web \
admin site docs grant-admin revoke-admin gen-key installer-sha installer-check installer-demo \
db-reset db-wipe migrate
db-reset db-wipe migrate warmbly warmbly-dist cli-sha cli-check
setup-tools:
@echo "Installing required Go tools into $(GO_BIN)"
@@ -50,6 +50,33 @@ setup-tools:
GOBIN=$(GO_BIN) go install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION)
GOBIN=$(GO_BIN) go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION)
# Build the `warmbly` CLI into ./bin, stamped with this checkout's version so
# `warmbly version` reports something meaningful. This is the customer CLI; the
# operator one (warmblyctl) ships in the backend image and runs there.
warmbly:
@mkdir -p bin
go build -ldflags="-s -w \
-X github.com/warmbly/warmbly/internal/version.Version=$(WARMBLY_BUILD_VERSION) \
-X github.com/warmbly/warmbly/internal/version.Commit=$(WARMBLY_BUILD_COMMIT) \
-X github.com/warmbly/warmbly/internal/version.BuiltAt=$(WARMBLY_BUILD_TIME)" \
-o bin/warmbly ./cmd/cli
@echo "built bin/warmbly ($(WARMBLY_BUILD_VERSION))"
@echo "put it on your PATH: sudo install -m 0755 bin/warmbly /usr/local/bin/warmbly"
# Everything a release publishes for the CLI: an archive per platform, the
# checksums, and the Homebrew and Scoop manifests. Same script the release
# workflow runs, so an artifact can be reproduced locally.
warmbly-dist:
./scripts/build-cli.sh dist
# The published installer at https://warmbly.com/cli.sh. Regenerate the
# checksum after any edit to it; CI fails when the two disagree.
cli-sha:
@cd site/public && sha256sum cli.sh > cli.sh.sha256 && cat cli.sh.sha256
cli-check:
@./scripts/check-cli-installer.sh
# Format all Go code. CI's golangci-lint enforces gofmt, so this is the
# formatting signal to run before committing, not `go build`.
fmt:
Executable
BIN
View File
Binary file not shown.
+6
View File
@@ -40,6 +40,7 @@ import (
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cipher"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -163,6 +164,7 @@ func main() {
var emailService email.EmailService
var poolLinkService poollink.Service
var cloudLinkService cloudlink.Service
var cliAuthService cliauth.Service
var campaignService campaign.CampaignService
var analyticsService analytics.AnalyticsService
var rateLimitService ratelimit.RateLimitService
@@ -1290,6 +1292,9 @@ func main() {
leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService)
apiKeyService = apikey.NewService(cache, apiKeyRepository)
// `warmbly auth login`: the browser approval mints an ordinary API key
// through the service above, so it has to be built after it.
cliAuthService = cliauth.NewService(repository.NewCLIAuthRepository(primaryDB.Pool), apiKeyService, organizationService, userService, organizationRepository)
crmService = crm.NewService(crmRepository)
teamRepository := repository.NewTeamRepository(primaryDB.Pool)
teamService = team.NewService(teamRepository)
@@ -1931,6 +1936,7 @@ func main() {
PoolLinkService: poolLinkService,
CloudLinkService: cloudLinkService,
CLIAuthService: cliAuthService,
TokenService: tokenService,
PasskeyService: passkeyService,
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
)
// Aliases are plain command lines stored in config.yml. They are expanded
// before cobra sees the arguments, so an alias can carry flags and the user
// can still add more on the end.
func newAliasCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "alias <command>",
Short: "Shortcuts for command lines you type often",
GroupID: groupSetup,
Long: `Save a command line under a shorter name.
Anything after the alias on the command line is appended, so an alias can be a
starting point rather than a fixed command.`,
Example: ` $ warmbly alias set hot "campaign list --status active"
$ warmbly hot --json`,
}
set := &cobra.Command{
Use: "set <name> <expansion>",
Short: "Create or replace an alias",
Args: cobra.ExactArgs(2),
RunE: func(c *cobra.Command, args []string) error {
name, expansion := args[0], args[1]
if strings.ContainsAny(name, " \t") {
return fmt.Errorf("an alias name cannot contain spaces")
}
// Shadowing a real command would make it unreachable, and the
// person who did it would have no way to tell why.
for _, existing := range c.Root().Commands() {
if existing.Name() == name {
return fmt.Errorf("%q is already a warmbly command, so an alias for it would hide it", name)
}
}
if _, err := splitArgs(expansion); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Aliases == nil {
cfg.Aliases = map[string]string{}
}
cfg.Aliases[name] = expansion
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s = %s\n", f.IO.Tick(), name, expansion)
return nil
},
}
list := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "Show every alias",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if len(cfg.Aliases) == 0 {
f.IO.Println(f.IO.Gray("No aliases yet. Try: warmbly alias set hot \"campaign list --status active\""))
return nil
}
names := make([]string, 0, len(cfg.Aliases))
for name := range cfg.Aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
f.IO.Printf("%-12s %s\n", name, cfg.Aliases[name])
}
return nil
},
}
del := &cobra.Command{
Use: "delete <name>",
Aliases: []string{"rm"},
Short: "Remove an alias",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if _, ok := cfg.Aliases[args[0]]; !ok {
return fmt.Errorf("no alias called %q", args[0])
}
delete(cfg.Aliases, args[0])
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s removed %s\n", f.IO.Tick(), args[0])
return nil
},
}
cmd.AddCommand(set, list, del)
return cmd
}
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/output"
)
// newAPICmd is the escape hatch that makes the typed commands optional: every
// endpoint is reachable on day one, whether or not a noun-verb command for it
// exists yet.
func newAPICmd(f *Factory) *cobra.Command {
var (
method string
rawField []string
field []string
headers []string
input string
paginate bool
maxPages int
include bool
silent bool
idemKey string
)
cmd := &cobra.Command{
Use: "api <endpoint>",
Short: "Call any Warmbly API endpoint",
GroupID: groupDevelop,
Long: `Make an authenticated request to the Warmbly REST API.
The endpoint is relative to /v1 unless it already names a version, so
"/campaigns" and "/v1/campaigns" are the same call. The method defaults to GET,
or POST when any field is supplied.
Fields build a JSON body. -f keeps the value a string; -F guesses the type, so
true, false, null and numbers arrive as themselves, and @file or @- reads a
value from a file or stdin. Nested keys use key[sub]=value and repeated key[]
builds an array.`,
Example: ` $ warmbly api /me
$ warmbly api "/campaigns?limit=10" --paginate
$ warmbly api /contacts -f email=jane@example.com -f first_name=Jane
$ warmbly api /campaigns/CAMPAIGN_ID -X PATCH -F daily_limit=40
$ warmbly api /contacts/search -X POST --input filter.json
$ warmbly api /webhooks/WEBHOOK_ID -X DELETE`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
client, err := f.Client()
if err != nil {
return err
}
parsed, err := url.Parse(args[0])
if err != nil {
return fmt.Errorf("%q is not a usable endpoint: %w", args[0], err)
}
body, err := bodyFromArg(f.IO.In, input)
if err != nil {
return err
}
fields, err := buildFields(rawField, field, f.IO.In)
if err != nil {
return err
}
if len(fields) > 0 {
if body != nil {
return fmt.Errorf("pass fields or --input, not both")
}
body, err = json.Marshal(fields)
if err != nil {
return err
}
}
if body != nil && !json.Valid(body) {
return fmt.Errorf("the request body is not valid JSON")
}
if method == "" {
method = http.MethodGet
if body != nil {
method = http.MethodPost
}
}
method = strings.ToUpper(method)
if method == http.MethodGet && body != nil {
return fmt.Errorf("a GET request carries no body. Put parameters in the query string, or pass -X POST.")
}
req := api.Request{
Method: method,
Path: parsed.Path,
Query: parsed.Query(),
Body: body,
IdempotencyKey: idemKey,
Headers: map[string]string{},
}
for _, h := range headers {
name, value, ok := strings.Cut(h, ":")
if !ok {
return fmt.Errorf("headers are name:value, not %q", h)
}
req.Headers[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
if paginate {
if method != http.MethodGet {
return fmt.Errorf("--paginate only makes sense on a GET")
}
merged, perr := client.Paginate(c.Context(), req, maxPages)
if perr != nil {
return perr
}
if silent {
return nil
}
return (&output.Printer{IO: f.IO, JSON: true}).Print(merged, output.Table{})
}
resp, err := client.Do(c.Context(), req)
if resp != nil && include {
f.IO.Printf("HTTP %d\n", resp.Status)
for name, values := range resp.Header {
for _, v := range values {
f.IO.Printf("%s: %s\n", name, v)
}
}
f.IO.Println()
}
if err != nil {
return err
}
if silent {
return nil
}
return (&output.Printer{IO: f.IO, JSON: true, Template: f.Template}).Print(resp.Body, output.Table{})
},
}
cmd.Flags().StringVarP(&method, "method", "X", "", "HTTP method (default GET, or POST when fields are given)")
cmd.Flags().StringArrayVarP(&rawField, "raw-field", "f", nil, "Body field as a string: key=value")
cmd.Flags().StringArrayVarP(&field, "field", "F", nil, "Body field with a guessed type: key=value, key=@file")
cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "Extra request header: name:value")
cmd.Flags().StringVar(&input, "input", "", "Request body: JSON, @file, or - for stdin")
cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow the cursor and merge every page")
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Stop after this many pages")
cmd.Flags().BoolVarP(&include, "include", "i", false, "Print the status and response headers too")
cmd.Flags().BoolVar(&silent, "silent", false, "Do not print the response body")
cmd.Flags().StringVar(&idemKey, "idempotency-key", "", "Idempotency-Key header for a safely retryable write")
return cmd
}
// buildFields turns -f and -F into one JSON object. Order matters only for
// duplicate keys, where the last one wins, as in curl.
func buildFields(raw, typed []string, stdin io.Reader) (map[string]any, error) {
out := map[string]any{}
for _, kv := range raw {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("fields are key=value, not %q", kv)
}
if err := assign(out, key, value); err != nil {
return nil, err
}
}
for _, kv := range typed {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("fields are key=value, not %q", kv)
}
if strings.HasPrefix(value, "@") {
source := value
if value == "@-" {
source = "-"
}
data, err := bodyFromArg(stdin, source)
if err != nil {
return nil, err
}
if err := assign(out, key, strings.TrimRight(string(data), "\n")); err != nil {
return nil, err
}
continue
}
if err := assign(out, key, guessType(value)); err != nil {
return nil, err
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// guessType is the -F conversion: JSON literals become themselves, everything
// else stays a string.
func guessType(v string) any {
switch v {
case "true":
return true
case "false":
return false
case "null":
return nil
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
if fl, err := strconv.ParseFloat(v, 64); err == nil {
return fl
}
return v
}
// assign writes one field, understanding key[sub] for nesting and key[] for
// appending to an array.
func assign(obj map[string]any, key string, value any) error {
open := strings.Index(key, "[")
if open < 0 {
obj[key] = value
return nil
}
if !strings.HasSuffix(key, "]") {
return fmt.Errorf("unbalanced brackets in field %q", key)
}
head := key[:open]
inner := key[open+1 : len(key)-1]
if head == "" {
return fmt.Errorf("field %q has no name", key)
}
if inner == "" {
existing, _ := obj[head].([]any)
obj[head] = append(existing, value)
return nil
}
nested, ok := obj[head].(map[string]any)
if !ok {
nested = map[string]any{}
obj[head] = nested
}
return assign(nested, inner, value)
}
+747
View File
@@ -0,0 +1,747 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/oauth"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
)
func newAuthCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth <command>",
Short: "Sign in, sign out, and see who you are",
GroupID: groupCore,
Long: `Sign the CLI in to a Warmbly instance.
Signing in through the browser creates one API key named for this machine. It
appears under Settings > API keys and can be revoked there or with
` + "`warmbly auth logout`" + `. Credentials are written to ` + config.HostsPath() + `
at 0600, and WARMBLY_TOKEN overrides the file without ever being written to it,
which is how CI authenticates with no login step.`,
}
cmd.AddCommand(
newAuthLoginCmd(f),
newAuthStatusCmd(f),
newAuthTokenCmd(f),
newAuthSwitchCmd(f),
newAuthRefreshCmd(f),
newAuthLogoutCmd(f),
)
return cmd
}
// parseScopes accepts the two presets plus any comma or space separated list
// of scope names, in either case, so `--scopes read_campaigns,READ_CONTACTS`
// works and a typo is named rather than silently dropped.
func parseScopes(raw string) (uint64, error) {
raw = strings.TrimSpace(raw)
switch strings.ToLower(raw) {
case "":
return models.APIPermFullAccess, nil
case "full", "full-access", "all":
return models.APIPermFullAccess, nil
case "read", "read-only", "readonly":
return models.APIPermReadOnly, nil
}
mask, unknown := oauth.ParseScopes(strings.NewReplacer(",", " ", "+", " ").Replace(raw))
if len(unknown) > 0 {
return 0, fmt.Errorf("unknown scope %s.\nRun `warmbly key permissions` for the full list, or use the presets: full, read-only.", strings.Join(unknown, ", "))
}
if mask == 0 {
return 0, fmt.Errorf("--scopes granted nothing. Name at least one scope, or use full or read-only.")
}
return mask, nil
}
func newAuthLoginCmd(f *Factory) *cobra.Command {
var (
hostname string
apiURL string
withToken bool
web bool
scopeStr string
force bool
)
cmd := &cobra.Command{
Use: "login",
Short: "Sign in to a Warmbly instance",
Long: `Sign in to a Warmbly instance.
With no flags this asks which instance, then how: a browser approval that
creates a key for this machine, or pasting a key you already have.`,
Example: ` $ warmbly auth login
$ warmbly auth login --hostname warmbly.acme.com
$ warmbly auth login --scopes read-only
$ echo $KEY | warmbly auth login --with-token`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthLogin(c.Context(), f, hostname, apiURL, withToken, web, scopeStr, force)
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Instance to sign in to (default: warmbly.com)")
cmd.Flags().StringVar(&apiURL, "api-url", "", "API base URL, when it is not derivable from the hostname")
cmd.Flags().BoolVar(&withToken, "with-token", false, "Read an API key from stdin instead of using the browser")
cmd.Flags().BoolVarP(&web, "web", "w", false, "Go straight to the browser approval")
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
cmd.Flags().BoolVar(&force, "force", false, "Replace an existing sign-in for this host without asking")
return cmd
}
func runAuthLogin(ctx context.Context, f *Factory, hostname, apiURL string, withToken, web bool, scopeStr string, force bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
// 1. Which instance.
if hostname == "" && apiURL == "" {
if !io.IsStdinTTY() {
hostname = config.DefaultHost
} else {
idx, serr := io.Select("Where do you want to sign in?", []string{
"warmbly.com (the hosted service)",
"A self-hosted instance",
})
if serr != nil {
return serr
}
if idx == 0 {
hostname = config.DefaultHost
} else {
answer, ierr := io.Input("Instance hostname (for example warmbly.acme.com)", "")
if ierr != nil {
return ierr
}
if strings.TrimSpace(answer) == "" {
return fmt.Errorf("a hostname is required to sign in to a self-hosted instance")
}
hostname = answer
}
}
}
if hostname == "" {
hostname = apiURL
}
host := config.NormalizeHost(hostname)
if existing := hosts[host]; existing != nil && !force {
if !io.IsStdinTTY() {
return fmt.Errorf("already signed in to %s as %s. Pass --force to replace that sign-in.", host, existing.User)
}
ok, cerr := io.Confirm(fmt.Sprintf("Already signed in to %s as %s. Sign in again?", host, existing.User), false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
// 2. Where its API is.
base, err := resolveAPIBase(ctx, f, host, apiURL)
if err != nil {
return err
}
// 3. How to authenticate.
useToken := withToken
if !withToken && !web && io.IsStdinTTY() {
idx, serr := io.Select("How do you want to sign in?", []string{
"Approve in a browser (creates a key for this machine)",
"Paste an API key you already have",
})
if serr != nil {
return serr
}
useToken = idx == 1
}
scopes, err := parseScopes(scopeStr)
if err != nil {
return err
}
var entry *config.Host
if useToken {
entry, err = loginWithToken(ctx, f, base)
} else {
entry, err = loginWithBrowser(ctx, f, cfg, base, scopes)
}
if err != nil {
return err
}
hosts[host] = entry
if err := hosts.Save(); err != nil {
return err
}
// Signing in makes that host the active one. Nobody expects to sign in and
// still have the next command talk to somewhere else.
if cfg.ActiveHost != host {
cfg.ActiveHost = host
if err := cfg.Save(); err != nil {
return err
}
}
io.Errorf("%s Signed in to %s as %s\n", io.Tick(), io.Bold(host), io.Bold(entry.User))
if entry.Organization != "" {
io.Errorf(" Workspace %s\n", entry.Organization)
}
io.Errorf(" Credentials written to %s\n", config.HostsPath())
return nil
}
// resolveAPIBase finds the API for a host, probing the layouts the installer
// writes. Guessing wrong here produces a confusing 404 on every later command,
// so it is settled once, at sign-in, and stored.
func resolveAPIBase(ctx context.Context, f *Factory, host, explicit string) (string, error) {
if explicit != "" {
return strings.TrimRight(explicit, "/"), nil
}
if v := strings.TrimSpace(os.Getenv(config.APIURLEnv)); v != "" {
return strings.TrimRight(v, "/"), nil
}
if host == config.DefaultHost {
return config.DefaultAPIURL(host), nil
}
candidates := config.CandidateAPIURLs(host)
for _, base := range candidates {
if reachable(ctx, f, base) {
if f.Debug {
f.IO.Errorf("* resolved API base %s\n", base)
}
return base, nil
}
}
return "", fmt.Errorf("could not find a Warmbly API for %s. Tried:\n %s\nPass --api-url with the instance's API base URL (the API_PUBLIC_URL it is configured with).", host, strings.Join(candidates, "\n "))
}
// deploymentConfig is the slice of GET /auth/config the CLI uses: enough to
// tell a Warmbly API from any other 200, plus the two URLs a client cannot
// derive on a self-hosted instance.
type deploymentConfig struct {
Registration string `json:"registration"`
AppURL string `json:"app_url"`
WebsocketURL string `json:"websocket_url"`
}
// fetchDeploymentConfig reads the public deployment facts, or nil when the
// base is not a Warmbly API. /health alone would accept any 200.
func fetchDeploymentConfig(ctx context.Context, f *Factory, base string) *deploymentConfig {
client := api.New(base, "", UserAgent())
client.HTTP.Timeout = 8 * time.Second
if f.Debug {
client.Debug = f.IO.ErrOut
}
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
resp, err := client.Do(probeCtx, api.Request{Method: http.MethodGet, Path: "/auth/config", Anonymous: true})
if err != nil || resp == nil || resp.Status != http.StatusOK {
return nil
}
var probe deploymentConfig
if json.Unmarshal(resp.Body, &probe) != nil || probe.Registration == "" {
return nil
}
return &probe
}
func reachable(ctx context.Context, f *Factory, base string) bool {
return fetchDeploymentConfig(ctx, f, base) != nil
}
func loginWithToken(ctx context.Context, f *Factory, base string) (*config.Host, error) {
io := f.IO
if io.IsStdinTTY() {
io.Errorln(io.Gray("Create a key under Settings > API keys, then paste it here. It is not echoed."))
}
token, err := io.Secret("API key")
if err != nil {
return nil, err
}
token = strings.TrimSpace(token)
if token == "" {
return nil, fmt.Errorf("no key was given")
}
if !strings.HasPrefix(token, apikey.KeyPrefix) {
return nil, fmt.Errorf("that does not look like a Warmbly API key: it should start with %q", apikey.KeyPrefix)
}
entry := &config.Host{APIURL: base, Token: token, AddedAt: time.Now().UTC()}
if err := fillIdentity(ctx, f, entry); err != nil {
return nil, err
}
if cfg := fetchDeploymentConfig(ctx, f, base); cfg != nil {
entry.AppURL = cfg.AppURL
}
return entry, nil
}
func loginWithBrowser(ctx context.Context, f *Factory, cfg *config.Config, base string, scopes uint64) (*config.Host, error) {
io := f.IO
client := api.New(base, "", UserAgent())
if f.Debug {
client.Debug = io.ErrOut
}
machine, _ := os.Hostname()
start, err := startDeviceFlow(ctx, client, machine, scopes)
if err != nil {
return nil, err
}
target := start.VerificationURLComplete
if target == "" {
target = start.VerificationURL
}
io.Errorf("\n %s %s\n", io.Gray("Your code:"), io.Bold(start.UserCode))
io.Errorf(" %s %s\n\n", io.Gray("Approve at:"), target)
if io.IsStdinTTY() {
if ok, cerr := io.Confirm("Open that in your browser now?", true); cerr == nil && ok {
if berr := openBrowser(cfg, target); berr != nil {
io.Errorln(io.Gray("Could not open a browser. Use the link above."))
}
}
}
io.Errorf("%s Waiting for approval (the code expires in %d minutes)\n", io.Gray("…"), start.ExpiresIn/60)
result, err := pollDeviceFlow(ctx, client, start)
if err != nil {
return nil, err
}
entry := &config.Host{
APIURL: base,
AppURL: appURLFromVerification(start.VerificationURL),
Token: result.Token,
User: result.UserEmail,
UserID: result.UserID,
Organization: result.OrganizationName,
OrganizationID: result.OrganizationID,
Scopes: result.ScopeNames,
APIKeyID: result.APIKeyID,
AddedAt: time.Now().UTC(),
}
if entry.User == "" {
// The approval did not carry an identity; ask the API who we are.
if err := fillIdentity(ctx, f, entry); err != nil {
return nil, err
}
}
return entry, nil
}
// appURLFromVerification recovers the dashboard origin from the approval link
// the instance just handed us, which is the instance's own APP_URL and so is
// exact where a hostname guess is not.
func appURLFromVerification(raw string) string {
return strings.TrimSuffix(strings.TrimSpace(raw), "/cli")
}
// fillIdentity calls /me, which both validates the credential and gives the
// host entry the labels `auth status` prints.
func fillIdentity(ctx context.Context, f *Factory, entry *config.Host) error {
client := api.New(entry.APIURL, entry.Token, UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
var id models.Identity
if err := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/me"}, &id); err != nil {
if api.StatusOf(err) == http.StatusUnauthorized {
return fmt.Errorf("that key was rejected by %s. Check it is a key for this instance and has not been revoked.", entry.APIURL)
}
return err
}
entry.User = id.Email
entry.UserID = id.UserID.String()
entry.Scopes = id.Scopes
entry.Organization = id.OrganizationName
if id.OrganizationID != nil {
entry.OrganizationID = id.OrganizationID.String()
}
return nil
}
func newAuthStatusCmd(f *Factory) *cobra.Command {
var showToken bool
cmd := &cobra.Command{
Use: "status",
Short: "Show which hosts you are signed in to",
Long: `Show every signed-in host, who you are on it, and where the credential
came from. Run this first when a command fails with a credential error: an
environment variable nobody remembers exporting is the usual answer.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthStatus(c.Context(), f, showToken)
},
}
cmd.Flags().BoolVarP(&showToken, "show-token", "t", false, "Print the token itself")
return cmd
}
func runAuthStatus(ctx context.Context, f *Factory, showToken bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
resolved, resolveErr := f.Resolved()
names := hosts.Names()
// A token from the environment points at a host that may not be in the
// file at all; it still deserves a line.
if resolveErr == nil && hosts[resolved.Host] == nil {
names = append(names, resolved.Host)
}
if len(names) == 0 {
io.Errorf("%s Not signed in anywhere.\n", io.Cross())
io.Errorln(io.Gray("Run `warmbly auth login` to sign in."))
return errSilent
}
sort.Strings(names)
failed := false
for _, name := range names {
active := resolveErr == nil && name == resolved.Host
marker := " "
if active {
marker = io.Green("* ")
}
io.Printf("%s%s\n", marker, io.Bold(name))
entry := hosts[name]
token := ""
source := config.HostsPath()
base := ""
if entry != nil {
token, base = entry.Token, entry.APIURL
}
if active {
token, source, base = resolved.Token, resolved.Source, resolved.APIURL
}
if token == "" {
io.Printf(" %s no credential\n", io.Cross())
failed = true
continue
}
probe := &config.Host{APIURL: base, Token: token}
if err := fillIdentity(ctx, f, probe); err != nil {
io.Printf(" %s %s\n", io.Cross(), err.Error())
failed = true
} else {
io.Printf(" %s signed in as %s\n", io.Tick(), io.Bold(probe.User))
if probe.Organization != "" {
io.Printf(" - workspace: %s\n", probe.Organization)
}
io.Printf(" - scopes: %s\n", scopeSummary(probe.Scopes))
}
io.Printf(" - api: %s\n", base)
io.Printf(" - token from: %s\n", source)
if showToken {
io.Printf(" - token: %s\n", token)
} else {
io.Printf(" - token: %s\n", maskToken(token))
}
}
if cfg.ActiveHost != "" && len(names) > 1 {
io.Println()
io.Println(io.Gray("The * host is the one commands use. `warmbly auth switch` changes it."))
}
if failed {
return errSilent
}
return nil
}
// scopeSummary keeps a full-access key from printing twenty-four lines.
func scopeSummary(scopes []string) string {
if len(scopes) == 0 {
return "none reported"
}
if len(scopes) >= len(models.AllAPIPermissions) {
return fmt.Sprintf("all %d", len(scopes))
}
if len(scopes) > 6 {
return fmt.Sprintf("%s and %d more", strings.Join(scopes[:6], ", "), len(scopes)-6)
}
return strings.Join(scopes, ", ")
}
func maskToken(t string) string {
if len(t) <= 12 {
return strings.Repeat("*", len(t))
}
return t[:8] + strings.Repeat("*", 8) + t[len(t)-4:]
}
func newAuthTokenCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Print the token the CLI is using",
Long: `Print the active token on stdout and nothing else, so it can be piped
into another tool or exported into a CI environment.`,
Example: ` $ export WARMBLY_TOKEN=$(warmbly auth token)
$ curl -H "Authorization: Bearer $(warmbly auth token)" https://api.warmbly.com/v1/me`,
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
r, err := f.Resolved()
if err != nil {
return err
}
f.IO.Println(r.Token)
return nil
},
}
}
func newAuthSwitchCmd(f *Factory) *cobra.Command {
var hostname string
cmd := &cobra.Command{
Use: "switch",
Short: "Change which signed-in host commands use",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 1 {
hostname = args[0]
}
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
names := hosts.Names()
if len(names) == 0 {
return fmt.Errorf("not signed in anywhere. Run `warmbly auth login` first.")
}
if hostname == "" {
if len(names) == 1 {
hostname = names[0]
} else {
labels := make([]string, len(names))
for i, n := range names {
labels[i] = n
if hosts[n].User != "" {
labels[i] += " " + f.IO.Gray(hosts[n].User)
}
}
idx, serr := f.IO.Select("Use which host?", labels)
if serr != nil {
return serr
}
hostname = names[idx]
}
}
host := config.NormalizeHost(hostname)
if hosts[host] == nil {
return fmt.Errorf("not signed in to %s. Signed in to: %s", host, strings.Join(names, ", "))
}
cfg.ActiveHost = host
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s Now using %s\n", f.IO.Tick(), f.IO.Bold(host))
return nil
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to switch to")
return cmd
}
func newAuthRefreshCmd(f *Factory) *cobra.Command {
var scopeStr string
cmd := &cobra.Command{
Use: "refresh",
Short: "Sign in again, usually to add scopes",
Long: `Run the browser sign-in again for the active host.
A key's scopes are fixed when it is created, so widening what the CLI may do
means a new key. The old one is revoked once the new one works.`,
Example: ` $ warmbly auth refresh --scopes full
$ warmbly auth refresh --scopes read_campaigns,send_campaigns`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
r, err := f.Resolved()
if err != nil {
return err
}
old := r.Entry
if err := runAuthLogin(c.Context(), f, r.Host, r.APIURL, false, true, scopeStr, true); err != nil {
return err
}
// Revoke the key we replaced, so refreshing does not accumulate a
// key per run under Settings > API keys.
if old != nil && old.APIKeyID != "" {
if err := revokeKey(c.Context(), f, old.APIKeyID); err != nil && f.Debug {
f.IO.Errorf("* could not revoke the previous key: %v\n", err)
}
}
return nil
},
}
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
return cmd
}
func newAuthLogoutCmd(f *Factory) *cobra.Command {
var hostname string
var keepKey bool
cmd := &cobra.Command{
Use: "logout",
Short: "Sign out and revoke this machine's key",
Long: `Forget a host's credential.
The key the sign-in created is revoked on the instance too, so signing out on
a machine you are handing back actually ends its access. --keep-key skips the
revocation, for a key you pasted in and use elsewhere.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthLogout(c.Context(), f, hostname, keepKey)
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to sign out of (default: the active one)")
cmd.Flags().BoolVar(&keepKey, "keep-key", false, "Forget the credential locally without revoking it")
return cmd
}
func runAuthLogout(ctx context.Context, f *Factory, hostname string, keepKey bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
host := config.NormalizeHost(hostname)
if hostname == "" {
r, rerr := f.Resolved()
if rerr != nil {
return rerr
}
host = r.Host
}
entry := hosts[host]
if entry == nil {
return fmt.Errorf("not signed in to %s", host)
}
if !f.AssumeYes && io.IsStdinTTY() {
ok, cerr := io.Confirm(fmt.Sprintf("Sign out of %s as %s?", host, entry.User), false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
// Revoke first: if it fails, the credential is still in the file and the
// user can retry, which is better than a live key nobody can reach.
revoked := false
if !keepKey && entry.APIKeyID != "" {
f.hosts = hosts
if err := revokeKeyWith(ctx, f, entry, entry.APIKeyID); err != nil {
io.Errorf("%s Could not revoke the key on %s: %v\n", io.Yellow("!"), host, err)
io.Errorln(io.Gray("Revoke it by hand under Settings > API keys."))
} else {
revoked = true
}
}
delete(hosts, host)
if err := hosts.Save(); err != nil {
return err
}
if cfg.ActiveHost == host {
cfg.ActiveHost = ""
if names := hosts.Names(); len(names) == 1 {
cfg.ActiveHost = names[0]
}
if err := cfg.Save(); err != nil {
return err
}
}
io.Errorf("%s Signed out of %s\n", io.Tick(), io.Bold(host))
if revoked {
io.Errorln(io.Gray("The key this machine was using has been revoked."))
}
return nil
}
func revokeKey(ctx context.Context, f *Factory, keyID string) error {
r, err := f.Resolved()
if err != nil {
return err
}
return revokeKeyWith(ctx, f, r.Entry, keyID)
}
// revokeKeyWith ends a key using the credential in entry.
//
// It asks the instance to revoke the calling credential first: self-revocation
// needs no scope, so it works for a read-only sign-in, where deleting by id
// would be refused. The by-id path is the fallback for an instance that
// predates /api-keys/self, and for revoking a key that is not the caller.
func revokeKeyWith(ctx context.Context, f *Factory, entry *config.Host, keyID string) error {
if entry == nil {
return fmt.Errorf("no credential to revoke with")
}
client := api.New(entry.APIURL, entry.Token, UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
if keyID == "" || keyID == entry.APIKeyID {
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/self"})
if err == nil {
return nil
}
if status := api.StatusOf(err); status != http.StatusNotFound && status != http.StatusMethodNotAllowed {
return err
}
if keyID == "" {
return err
}
}
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/" + keyID})
if err != nil && api.StatusOf(err) == http.StatusNotFound {
// Already gone is the outcome we wanted.
return nil
}
return err
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
)
// browseTargets maps a noun to its dashboard path, so `warmbly browse
// campaigns` does not require anyone to remember the URL layout.
var browseTargets = map[string]string{
"campaigns": "/app/campaigns",
"campaign": "/app/campaigns",
"contacts": "/app/contacts",
"contact": "/app/contacts",
"mailboxes": "/app/emails",
"mailbox": "/app/emails",
"emails": "/app/emails",
"inbox": "/app/unibox",
"unibox": "/app/unibox",
"analytics": "/app/analytics",
"automations": "/app/automations",
"forms": "/app/forms",
"templates": "/app/templates",
"crm": "/app/crm",
"audit": "/app/audit",
"keys": "/app/api-keys",
"api-keys": "/app/api-keys",
"settings": "/app/settings/profile",
"webhooks": "/app/settings/webhooks",
"billing": "/app/settings/billing",
"members": "/app/settings/members",
"deliverability": "/app/deliverability",
}
// browseDetail is the subset that has a per-record page, for `warmbly browse
// campaign <id>`.
var browseDetail = map[string]string{
"campaign": "/app/campaigns",
"campaigns": "/app/campaigns",
"contact": "/app/contacts",
"contacts": "/app/contacts",
"mailbox": "/app/emails",
"mailboxes": "/app/emails",
"automation": "/app/automations",
"form": "/app/forms",
"forms": "/app/forms",
}
func newBrowseCmd(f *Factory) *cobra.Command {
var printOnly bool
cmd := &cobra.Command{
Use: "browse [<section>] [<id>]",
Short: "Open the dashboard in a browser",
GroupID: groupCore,
Long: `Open the Warmbly dashboard for the host you are signed in to.
With a section it goes straight there; with a section and an id it opens that
record. --no-browser prints the URL instead, which is what you want over SSH.`,
Example: ` $ warmbly browse
$ warmbly browse campaigns
$ warmbly browse campaign 6f1c...
$ warmbly browse inbox --no-browser`,
Args: cobra.MaximumNArgs(2),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
r, err := f.Resolved()
if err != nil {
return err
}
path := "/app/emails"
if len(args) > 0 {
section := strings.ToLower(args[0])
if len(args) > 1 {
detail, ok := browseDetail[section]
if !ok {
return fmt.Errorf("%q has no detail page to open by id. Sections with one: %s", args[0], keysOf(browseDetail))
}
path = detail + "/" + args[1]
} else {
target, ok := browseTargets[section]
if !ok {
return fmt.Errorf("nothing to browse called %q. Try one of: %s", args[0], keysOf(browseTargets))
}
path = target
}
}
url := dashboardURL(r) + path
if printOnly || !f.IO.IsStdoutTTY() {
f.IO.Println(url)
return nil
}
f.IO.Errorf("%s Opening %s\n", f.IO.Gray("→"), url)
if err := openBrowser(cfg, url); err != nil {
f.IO.Println(url)
}
return nil
},
}
cmd.Flags().BoolVar(&printOnly, "no-browser", false, "Print the URL instead of opening it")
return cmd
}
// dashboardURL is where this host's dashboard lives. The instance reports its
// own APP_URL at sign-in, which is exact; the derivation below is the fallback
// for a credential that came from the environment and never signed in.
func dashboardURL(r *config.Resolved) string {
if r.Entry != nil && strings.TrimSpace(r.Entry.AppURL) != "" {
return strings.TrimRight(r.Entry.AppURL, "/")
}
return appBaseURL(r.Host)
}
// appBaseURL is the dashboard for a host, following the layout the installer
// writes: app.<host> for a real deployment, the host itself for a local one.
func appBaseURL(host string) string {
host = config.NormalizeHost(host)
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
return "http://" + host
}
if strings.Contains(host, ":") {
return "http://" + host
}
return "https://app." + host
}
// keysOf lists a target map's names, sorted, for an error message.
func keysOf(m map[string]string) string {
out := make([]string, 0, len(m))
for name := range m {
out = append(out, name)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
+301
View File
@@ -0,0 +1,301 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
)
func TestFillPath(t *testing.T) {
specs := []argSpec{{Name: "id"}, {Name: "step"}}
got, err := fillPath("/campaigns/{id}/steps/{step}", specs, []string{"abc", "def"})
if err != nil {
t.Fatalf("fill: %v", err)
}
if got != "/campaigns/abc/steps/def" {
t.Errorf("path = %q", got)
}
if _, err := fillPath("/campaigns/{id}", specs[:1], []string{" "}); err == nil {
t.Error("an empty argument must be rejected rather than producing /campaigns/")
}
// A tool name can contain characters that need escaping in a path.
got, err = fillPath("/ai/tools/{name}/call", []argSpec{{Name: "name"}}, []string{"a b"})
if err != nil {
t.Fatalf("fill: %v", err)
}
if !strings.Contains(got, "a%20b") {
t.Errorf("path segment was not escaped: %q", got)
}
}
// Every spec has to produce a runnable command: one {} per positional argument
// and no leftovers, or the command is dead on arrival at runtime.
func TestEverySpecPathMatchesItsArguments(t *testing.T) {
seen := map[string]bool{}
for _, r := range resourceSpecs() {
if seen[r.Name] {
t.Errorf("two resources are called %q", r.Name)
}
seen[r.Name] = true
endpoints := map[string]bool{}
for _, e := range r.Endpoints {
if endpoints[e.Name] {
t.Errorf("%s has two %q commands", r.Name, e.Name)
}
endpoints[e.Name] = true
if e.Method == "" || e.Path == "" || e.Short == "" {
t.Errorf("%s %s is missing a method, path or summary", r.Name, e.Name)
}
if !strings.HasPrefix(e.Path, "/") {
t.Errorf("%s %s path %q must be /v1-relative", r.Name, e.Name, e.Path)
}
placeholders := strings.Count(e.Path, "{")
if placeholders != len(e.Args) {
t.Errorf("%s %s has %d placeholders and %d arguments", r.Name, e.Name, placeholders, len(e.Args))
}
args := make([]string, len(e.Args))
for i := range e.Args {
args[i] = "x"
if e.Args[i].Help == "" {
t.Errorf("%s %s argument %q has no help", r.Name, e.Name, e.Args[i].Name)
}
}
if _, err := fillPath(e.Path, e.Args, args); err != nil {
t.Errorf("%s %s: %v", r.Name, e.Name, err)
}
if e.Method == http.MethodGet && e.Body != bodyNone {
t.Errorf("%s %s is a GET with a body", r.Name, e.Name)
}
flags := map[string]bool{}
for _, fl := range e.Flag {
if flags[fl.Name] {
t.Errorf("%s %s declares --%s twice", r.Name, e.Name, fl.Name)
}
flags[fl.Name] = true
if fl.Help == "" {
t.Errorf("%s %s flag --%s has no help", r.Name, e.Name, fl.Name)
}
// -h is cobra's help shorthand; claiming it breaks the command.
if fl.Short == "h" {
t.Errorf("%s %s cannot use -h for --%s", r.Name, e.Name, fl.Name)
}
}
}
}
}
// Building the whole command tree catches the failures cobra reports by
// panicking: a duplicate shorthand, a bad group id.
func TestCommandTreeBuilds(t *testing.T) {
f := NewFactory()
root := newRootCmd(f)
if len(root.Commands()) == 0 {
t.Fatal("no commands registered")
}
for _, c := range root.Commands() {
if c.GroupID == "" && c.Name() != "help" && c.Name() != "completion" {
t.Errorf("%s has no group, so it falls out of the grouped help", c.Name())
}
for _, sub := range c.Commands() {
if sub.Short == "" {
t.Errorf("%s %s has no summary", c.Name(), sub.Name())
}
}
}
}
func TestBuildFields(t *testing.T) {
body, err := buildFields(
[]string{"name=Jane", "note=true"},
[]string{"limit=40", "active=true", "missing=null", "tags[]=a", "tags[]=b", "nested[key]=v"},
strings.NewReader(""),
)
if err != nil {
t.Fatalf("build: %v", err)
}
if body["name"] != "Jane" {
t.Errorf("-f name should stay a string, got %#v", body["name"])
}
if body["note"] != "true" {
t.Errorf("-f keeps values literal, got %#v", body["note"])
}
if body["limit"] != int64(40) {
t.Errorf("-F limit should be a number, got %#v", body["limit"])
}
if body["active"] != true {
t.Errorf("-F active should be a bool, got %#v", body["active"])
}
if body["missing"] != nil {
t.Errorf("-F null should be null, got %#v", body["missing"])
}
tags, _ := body["tags"].([]any)
if len(tags) != 2 {
t.Errorf("key[] should build an array, got %#v", body["tags"])
}
nested, _ := body["nested"].(map[string]any)
if nested["key"] != "v" {
t.Errorf("key[sub] should nest, got %#v", body["nested"])
}
if _, err := buildFields([]string{"broken"}, nil, strings.NewReader("")); err == nil {
t.Error("a field with no = must be rejected")
}
}
func TestSplitArgs(t *testing.T) {
got, err := splitArgs(`campaign list --status "in progress" --q 'x y'`)
if err != nil {
t.Fatalf("split: %v", err)
}
want := []string{"campaign", "list", "--status", "in progress", "--q", "x y"}
if len(got) != len(want) {
t.Fatalf("got %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %#v, want %#v", got, want)
}
}
if _, err := splitArgs(`unbalanced "`); err == nil {
t.Error("an unbalanced quote must be an error, not a silent truncation")
}
// An empty quoted argument is still an argument.
if got, _ := splitArgs(`a "" b`); len(got) != 3 {
t.Errorf("empty quoted argument was dropped: %#v", got)
}
}
func TestExpandAliases(t *testing.T) {
dir := t.TempDir()
t.Setenv(config.DirEnv, dir)
cfg := &config.Config{Aliases: map[string]string{"hot": "campaign list --status active"}}
if err := cfg.Save(); err != nil {
t.Fatalf("save: %v", err)
}
f := NewFactory()
got := expandAliases(f, []string{"hot", "--json"})
want := []string{"campaign", "list", "--status", "active", "--json"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("expanded to %v, want %v", got, want)
}
// A non-alias is untouched.
if got := expandAliases(f, []string{"campaign", "list"}); got[0] != "campaign" {
t.Errorf("a real command was rewritten: %v", got)
}
}
func TestParseScopes(t *testing.T) {
if mask, err := parseScopes(""); err != nil || mask != models.APIPermFullAccess {
t.Errorf("empty should mean full access, got %d %v", mask, err)
}
if mask, err := parseScopes("read-only"); err != nil || mask != models.APIPermReadOnly {
t.Errorf("read-only preset = %d %v", mask, err)
}
mask, err := parseScopes("read_campaigns,SEND_CAMPAIGNS")
if err != nil {
t.Fatalf("named scopes: %v", err)
}
if mask&models.APIPermReadCampaigns == 0 || mask&models.APIPermSendCampaigns == 0 {
t.Errorf("named scopes did not resolve: %d", mask)
}
if _, err := parseScopes("not_a_scope"); err == nil {
t.Error("an unknown scope must be named, not dropped")
}
}
// The device flow is the whole sign-in, so it gets an end-to-end test against
// a server that behaves like the real one: pending, then approved once.
func TestDeviceFlow(t *testing.T) {
polls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/auth/cli/code":
var req models.CLIAuthStartRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.CLIVersion == "" || req.Scopes == 0 {
t.Errorf("the CLI must identify itself and name its scopes: %+v", req)
}
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"device_code":"dc","user_code":"ABCD-EFGH","verification_uri":"https://app.example/cli","verification_uri_complete":"https://app.example/cli?code=ABCD-EFGH","expires_in":600,"interval":1}`)
case "/v1/auth/cli/poll":
polls++
if polls < 2 {
fmt.Fprint(w, `{"status":"pending"}`)
return
}
fmt.Fprint(w, `{"status":"approved","token":"wmbly_minted","user_email":"jane@example.com","organization_name":"Acme","api_key_id":"key-1","scope_names":["READ_CAMPAIGNS"]}`)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
client := api.New(srv.URL, "", "test")
start, err := startDeviceFlow(context.Background(), client, "laptop", models.APIPermReadOnly)
if err != nil {
t.Fatalf("start: %v", err)
}
if start.UserCode != "ABCD-EFGH" {
t.Errorf("user code = %q", start.UserCode)
}
result, err := pollDeviceFlow(context.Background(), client, start)
if err != nil {
t.Fatalf("poll: %v", err)
}
if polls < 2 {
t.Errorf("the client stopped polling before approval")
}
if result.Token != "wmbly_minted" || result.UserEmail != "jane@example.com" {
t.Errorf("approval payload lost: %+v", result)
}
}
func TestDeviceFlowStopsOnDenial(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"denied"}`)
}))
defer srv.Close()
client := api.New(srv.URL, "", "test")
_, err := pollDeviceFlow(context.Background(), client, &deviceStart{DeviceCode: "dc", UserCode: "X", Interval: 1, ExpiresIn: 60})
if err == nil || !strings.Contains(err.Error(), "declined") {
t.Errorf("a denial must end the wait, got %v", err)
}
}
func TestAppURLFromVerification(t *testing.T) {
// The instance hands back its own APP_URL with /cli on the end, which is
// exact where a hostname guess is not.
if got := appURLFromVerification("https://app.acme.dev/cli"); got != "https://app.acme.dev" {
t.Errorf("got %q", got)
}
if got := appURLFromVerification("http://localhost:5173/cli"); got != "http://localhost:5173" {
t.Errorf("got %q", got)
}
if got := appURLFromVerification(""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestDashboardURLPrefersWhatTheInstanceReported(t *testing.T) {
r := &config.Resolved{Host: "acme.dev", Entry: &config.Host{AppURL: "https://warmbly.acme.dev/"}}
if got := dashboardURL(r); got != "https://warmbly.acme.dev" {
t.Errorf("got %q, want the reported origin", got)
}
// With nothing reported, fall back to the layout the installer writes.
if got := dashboardURL(&config.Resolved{Host: "acme.dev"}); got != "https://app.acme.dev" {
t.Errorf("fallback = %q", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
)
func newConfigCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config <command>",
Short: "Read and write the CLI's own settings",
GroupID: groupSetup,
Long: `Read and write ` + config.ConfigPath() + `.
These are preferences, not credentials: credentials live in hosts.yml and are
managed with ` + "`warmbly auth`" + `.`,
}
get := &cobra.Command{
Use: "get <key>",
Short: "Print one setting",
Args: cobra.ExactArgs(1),
RunE: func(*cobra.Command, []string) error { return nil },
}
get.RunE = func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if !knownKey(args[0]) {
return unknownKey(args[0])
}
f.IO.Println(cfg.Get(args[0]))
return nil
}
set := &cobra.Command{
Use: "set <key> <value>",
Short: "Change one setting",
Example: " $ warmbly config set output json\n $ warmbly config set confirm always",
Args: cobra.ExactArgs(2),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if err := cfg.Set(args[0], args[1]); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s = %s\n", f.IO.Tick(), args[0], args[1])
return nil
},
}
list := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "Show every setting and what it does",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
for _, k := range config.Keys {
value := cfg.Get(k.Name)
if value == "" {
value = f.IO.Gray("(unset, " + k.Default + ")")
}
f.IO.Printf("%-14s %s\n", k.Name, value)
f.IO.Printf("%-14s %s\n", "", f.IO.Gray(k.Help))
}
f.IO.Println()
f.IO.Printf("%s %s\n", f.IO.Gray("config:"), config.ConfigPath())
f.IO.Printf("%s %s\n", f.IO.Gray("hosts: "), config.HostsPath())
return nil
},
}
clear := &cobra.Command{
Use: "clear <key>",
Short: "Reset one setting to its default",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if !knownKey(args[0]) {
return unknownKey(args[0])
}
// Set validates values, so clearing goes through the zero value
// directly rather than through a value it would reject.
switch args[0] {
case "active_host":
cfg.ActiveHost = ""
case "output":
cfg.Output = ""
case "confirm":
cfg.Confirm = ""
case "pager":
cfg.Pager = ""
case "browser":
cfg.Browser = ""
}
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s reset\n", f.IO.Tick(), args[0])
return nil
},
}
cmd.AddCommand(get, set, list, clear)
return cmd
}
func knownKey(name string) bool {
for _, k := range config.Keys {
if k.Name == name {
return true
}
}
return false
}
func unknownKey(name string) error {
names := make([]string, 0, len(config.Keys))
for _, k := range config.Keys {
names = append(names, k.Name)
}
sort.Strings(names)
return fmt.Errorf("unknown config key %q. Settable keys: %s", name, strings.Join(names, ", "))
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"runtime"
"strings"
"time"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/version"
)
// The browser half of `warmbly auth login`, RFC 8628 shaped: ask for a code,
// show it, wait for a member to approve it in the browser, collect the key.
type deviceStart struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_uri"`
VerificationURLComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type devicePoll struct {
Status string `json:"status"`
Token string `json:"token"`
APIKeyID string `json:"api_key_id"`
ScopeNames []string `json:"scope_names"`
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
}
// startDeviceFlow opens the handshake. The client is anonymous: there is
// nothing to authenticate with yet.
func startDeviceFlow(ctx context.Context, client *api.Client, hostname string, scopes uint64) (*deviceStart, error) {
body, err := json.Marshal(models.CLIAuthStartRequest{
ClientName: "Warmbly CLI",
Hostname: hostname,
CLIVersion: version.String(),
Scopes: scopes,
})
if err != nil {
return nil, err
}
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/code", Body: body, Anonymous: true})
if err != nil {
if api.StatusOf(err) == http.StatusNotFound || api.StatusOf(err) == http.StatusNotImplemented {
return nil, fmt.Errorf("%s does not support browser sign-in for the CLI.\nUse `warmbly auth login --with-token` with an API key from Settings > API keys instead.", client.BaseURL)
}
return nil, err
}
var out deviceStart
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, fmt.Errorf("the sign-in handshake returned something unexpected: %w", err)
}
if out.DeviceCode == "" || out.UserCode == "" {
return nil, errors.New("the sign-in handshake returned no code")
}
if out.Interval <= 0 {
out.Interval = 3
}
if out.ExpiresIn <= 0 {
out.ExpiresIn = 600
}
return &out, nil
}
// pollDeviceFlow waits for the browser half. It stops on approval, denial,
// expiry, or the context being cancelled, and never faster than the interval
// the server asked for.
func pollDeviceFlow(ctx context.Context, client *api.Client, start *deviceStart) (*devicePoll, error) {
body, err := json.Marshal(map[string]string{"device_code": start.DeviceCode})
if err != nil {
return nil, err
}
interval := time.Duration(start.Interval) * time.Second
deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("the code %s expired before it was approved. Run `warmbly auth login` again.", start.UserCode)
}
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/poll", Body: body, Anonymous: true})
if err != nil {
// The code is gone: expired, or someone else claimed it.
if api.StatusOf(err) == http.StatusNotFound {
return nil, fmt.Errorf("the code %s is no longer valid. Run `warmbly auth login` again.", start.UserCode)
}
// A rate limit or a blip must not end a sign-in someone is
// standing at; back off and keep waiting.
if api.StatusOf(err) == http.StatusTooManyRequests || api.StatusOf(err) == 0 {
interval += time.Second
continue
}
return nil, err
}
var out devicePoll
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, err
}
switch out.Status {
case string(models.CLIAuthCodeApproved):
if out.Token == "" {
return nil, errors.New("the approval returned no token. Run `warmbly auth login` again.")
}
return &out, nil
case string(models.CLIAuthCodeDenied):
return nil, errors.New("the request was declined in the browser. Nothing was created.")
case string(models.CLIAuthCodeClaimed):
return nil, errors.New("that code was already used. Run `warmbly auth login` again.")
}
}
}
// openBrowser opens a URL, honouring the browser config key and then BROWSER.
// A failure is never fatal: the URL has already been printed.
func openBrowser(cfg *config.Config, url string) error {
if custom := strings.TrimSpace(cfg.Browser); custom != "" {
parts, err := splitArgs(custom)
if err != nil || len(parts) == 0 {
return fmt.Errorf("the browser config key is not a usable command")
}
return exec.Command(parts[0], append(parts[1:], url)...).Start()
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
// `warmbly events tail` is the terminal view of the developer WebSocket: the
// same stream the dashboard runs on, printed as it happens. It is the fastest
// way to see whether an integration is receiving what you think it is, without
// standing up a public webhook URL first.
//
// The socket speaks the Phoenix channel protocol, serializer 1.0.0: every
// frame is [join_ref, ref, topic, event, payload].
func newEventsCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "events <command>",
Short: "Watch live events as they happen",
GroupID: groupDevelop,
Long: `Subscribe to the workspace's live event stream.
This needs a key with the REALTIME_SUBSCRIBE scope. Sign in again with
` + "`warmbly auth refresh --scopes full`" + ` if the key you have does not carry it.`,
}
cmd.AddCommand(newEventsTailCmd(f))
return cmd
}
func newEventsTailCmd(f *Factory) *cobra.Command {
var (
intents []string
wsURL string
orgID string
compact bool
maxCount int
)
cmd := &cobra.Command{
Use: "tail",
Short: "Stream live events to the terminal",
Long: `Print events as Warmbly publishes them: sends, opens, clicks, replies,
inbox arrivals, campaign state, and the custom events your automations fire.
Filter with --intent, which matches the event type as a case-insensitive
substring, so --intent EMAIL takes EMAIL_SENT, EMAIL_OPENED and EMAIL_RECEIVED.
Intents reduce traffic; they are not a permission boundary, and the key's
scopes still decide what reaches you at all.`,
Example: ` $ warmbly events tail
$ warmbly events tail --intent EMAIL --intent CAMPAIGN
$ warmbly events tail --json | jq 'select(.event_type == "EMAIL_REPLIED")'`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runEventsTail(c.Context(), f, wsURL, orgID, intents, compact, maxCount)
},
}
cmd.Flags().StringArrayVar(&intents, "intent", nil, "Only these event families, for example EMAIL or CAMPAIGN")
cmd.Flags().StringVar(&wsURL, "url", "", "WebSocket URL, when the instance does not advertise one")
cmd.Flags().StringVar(&orgID, "org", "", "Organization to subscribe to (default: the signed-in one)")
cmd.Flags().BoolVar(&compact, "compact", false, "One line per event, even on a terminal")
cmd.Flags().IntVar(&maxCount, "count", 0, "Stop after this many events")
return cmd
}
func runEventsTail(ctx context.Context, f *Factory, wsURL, orgID string, intents []string, compact bool, maxCount int) error {
io := f.IO
r, err := f.Resolved()
if err != nil {
return err
}
if orgID == "" && r.Entry != nil {
orgID = r.Entry.OrganizationID
}
if orgID == "" {
// The key knows its own organization even when the config file does not.
client, cerr := f.Client()
if cerr != nil {
return cerr
}
var id struct {
OrganizationID string `json:"organization_id"`
}
if jerr := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/me"}, &id); jerr != nil {
return jerr
}
orgID = id.OrganizationID
}
if orgID == "" {
return fmt.Errorf("this credential is not scoped to a workspace, so there is no org channel to join")
}
endpoint, err := resolveSocketURL(ctx, f, r, wsURL)
if err != nil {
return err
}
target := endpoint
if !strings.Contains(target, "?") {
target += "?"
} else {
target += "&"
}
target += "vsn=1.0.0&token=" + url.QueryEscape(r.Token)
if f.Debug {
io.Errorf("* connecting to %s\n", endpoint)
}
dialer := websocket.Dialer{HandshakeTimeout: 20 * time.Second}
conn, resp, err := dialer.DialContext(ctx, target, nil)
if resp != nil && resp.Body != nil {
// The handshake response body carries the rejection reason and nothing
// the stream needs; the socket itself is what stays open.
defer resp.Body.Close()
}
if err != nil {
if resp != nil && resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("the realtime gateway refused this key.\nIt needs the REALTIME_SUBSCRIBE scope: `warmbly auth refresh --scopes full`.")
}
return fmt.Errorf("could not connect to the realtime gateway at %s: %w\nPass --url if this instance serves it somewhere else.", endpoint, err)
}
defer conn.Close()
topic := "org:" + orgID
payload := map[string]any{}
if len(intents) > 0 {
payload["intents"] = intents
}
if err := conn.WriteJSON([]any{"1", "1", topic, "phx_join", payload}); err != nil {
return err
}
io.Errorf("%s Listening on %s%s\n", io.Gray("…"), io.Bold(topic), intentSuffix(io, intents))
io.Errorln(io.Gray("Ctrl-C to stop."))
// Heartbeats are client-initiated. The join reply carries the cadence the
// server wants; until it arrives, the documented default is safe.
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
done := make(chan error, 1)
go func() {
seen := 0
for {
var frame []json.RawMessage
if err := conn.ReadJSON(&frame); err != nil {
done <- err
return
}
if len(frame) < 5 {
continue
}
var event string
var body json.RawMessage
_ = json.Unmarshal(frame[3], &event)
body = frame[4]
switch event {
case "phx_reply":
if hb := handleJoinReply(io, body, heartbeat); hb != nil {
done <- hb
return
}
continue
case "phx_close", "phx_error":
done <- fmt.Errorf("the channel closed. Rejoin with `warmbly events tail`.")
return
case "phx_join", "heartbeat":
continue
}
printEvent(f, event, body, compact)
seen++
if maxCount > 0 && seen >= maxCount {
done <- nil
return
}
}
}()
ref := 2
for {
select {
case <-ctx.Done():
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return nil
case err := <-done:
if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
return err
}
return nil
case <-heartbeat.C:
ref++
if err := conn.WriteJSON([]any{nil, fmt.Sprint(ref), "phoenix", "heartbeat", map[string]any{}}); err != nil {
return err
}
}
}
}
// handleJoinReply reads the HELLO the org channel answers a join with: it
// carries the heartbeat cadence, so the client does not hardcode one, and it
// is where a rejected join is reported.
func handleJoinReply(io *iostreams.IOStreams, body json.RawMessage, heartbeat *time.Ticker) error {
var reply struct {
Status string `json:"status"`
Response struct {
Role string `json:"role"`
HeartbeatIntervalMS int `json:"heartbeat_interval_ms"`
Seq int64 `json:"seq"`
Reason string `json:"reason"`
} `json:"response"`
}
if err := json.Unmarshal(body, &reply); err != nil {
return nil
}
if reply.Status == "error" {
reason := reply.Response.Reason
if reason == "" {
reason = "the gateway refused the join"
}
return fmt.Errorf("could not join the channel: %s", reason)
}
if reply.Response.HeartbeatIntervalMS > 1000 {
heartbeat.Reset(time.Duration(reply.Response.HeartbeatIntervalMS) * time.Millisecond)
}
if reply.Response.Role != "" {
io.Errorf("%s\n", io.Gray("Joined as "+reply.Response.Role+"."))
}
return nil
}
func printEvent(f *Factory, event string, body json.RawMessage, compact bool) {
io := f.IO
if f.JSONOut || !io.IsStdoutTTY() {
io.Println(strings.TrimSpace(string(body)))
return
}
var fields map[string]any
_ = json.Unmarshal(body, &fields)
stamp := time.Now().Format("15:04:05")
io.Printf("%s %s %s\n", io.Gray(stamp), eventColour(io, event), io.Gray(summarize(fields)))
if compact {
return
}
// The interesting ids, on one indented line, so a terminal stays readable
// while still carrying enough to look something up.
var parts []string
for _, key := range []string{"campaign_id", "contact_id", "email_id", "thread_id", "email_account_id", "name", "entity_type", "action"} {
if v, ok := fields[key]; ok && v != nil && fmt.Sprint(v) != "" {
parts = append(parts, fmt.Sprintf("%s=%v", key, v))
}
}
if len(parts) > 0 {
io.Printf(" %s\n", io.Gray(strings.Join(parts, " ")))
}
}
func eventColour(io *iostreams.IOStreams, event string) string {
switch {
case strings.Contains(event, "FAILED"), strings.Contains(event, "BOUNCE"), strings.Contains(event, "ERROR"):
return io.Red(event)
case strings.Contains(event, "REPLIED"), strings.Contains(event, "BOOKED"):
return io.Green(event)
case strings.Contains(event, "OPENED"), strings.Contains(event, "CLICKED"):
return io.Cyan(event)
default:
return io.Bold(event)
}
}
// summarize is the short human tail of an event line: whichever descriptive
// field the event happens to carry.
func summarize(fields map[string]any) string {
for _, key := range []string{"subject", "email", "to", "contact_email", "campaign_name", "message", "status", "name"} {
if v, ok := fields[key]; ok {
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "<nil>" {
if len(s) > 70 {
s = s[:69] + "…"
}
return s
}
}
}
return ""
}
// intentSuffix names the filter in the "listening" line, so a stream that goes
// quiet does not look broken when it is only filtered.
func intentSuffix(io *iostreams.IOStreams, intents []string) string {
if len(intents) == 0 {
return ""
}
return io.Gray(" (" + strings.Join(intents, ", ") + " only)")
}
// resolveSocketURL finds the realtime gateway: the flag, then what the
// instance advertises on /auth/config, then the layouts the installer writes.
func resolveSocketURL(ctx context.Context, f *Factory, r *config.Resolved, explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
client := api.New(r.APIURL, "", UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
var cfg struct {
WebsocketURL string `json:"websocket_url"`
}
if err := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/auth/config", Anonymous: true}, &cfg); err == nil && cfg.WebsocketURL != "" {
return cfg.WebsocketURL, nil
}
host := config.NormalizeHost(r.Host)
if host == config.DefaultHost {
return "wss://realtime." + config.DefaultHost + "/socket/websocket", nil
}
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
return "ws://" + strings.Split(host, ":")[0] + ":4000/socket/websocket", nil
}
// The installer's proxy and Caddy layouts both put it on ws.<host>.
return "wss://ws." + host + "/socket/websocket", nil
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"fmt"
"io"
"os"
"runtime"
"strings"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
"github.com/warmbly/warmbly/internal/cli/output"
"github.com/warmbly/warmbly/internal/version"
)
// Factory is what every command is handed: the terminal, the two config files,
// and a way to build an authenticated client. Building it lazily matters,
// because `warmbly auth login` and `warmbly version` must work before there is
// anything to authenticate with.
type Factory struct {
IO *iostreams.IOStreams
// Global flags, bound once on the root command.
HostFlag string
JSONOut bool
Template string
Fields []string
AssumeYes bool
NoColor bool
Debug bool
cfg *config.Config
hosts config.Hosts
}
func NewFactory() *Factory {
return &Factory{IO: iostreams.System()}
}
// UserAgent identifies the CLI in API usage logs, which is how an operator
// tells a CLI call from a script's.
func UserAgent() string {
return fmt.Sprintf("warmbly-cli/%s (%s/%s)", version.String(), runtime.GOOS, runtime.GOARCH)
}
func (f *Factory) Config() (*config.Config, error) {
if f.cfg != nil {
return f.cfg, nil
}
cfg, err := config.Load()
if err != nil {
return nil, err
}
f.cfg = cfg
return cfg, nil
}
func (f *Factory) Hosts() (config.Hosts, error) {
if f.hosts != nil {
return f.hosts, nil
}
hosts, err := config.LoadHosts()
if err != nil {
return nil, err
}
f.hosts = hosts
return hosts, nil
}
// Resolved answers which host and token this invocation uses.
func (f *Factory) Resolved() (*config.Resolved, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
hosts, err := f.Hosts()
if err != nil {
return nil, err
}
return config.Resolve(cfg, hosts, f.HostFlag)
}
// Client builds an authenticated client, or explains how to get one.
func (f *Factory) Client() (*api.Client, error) {
r, err := f.Resolved()
if err != nil {
return nil, err
}
c := api.New(r.APIURL, r.Token, UserAgent())
if f.Debug {
c.Debug = f.IO.ErrOut
}
return c, nil
}
// Printer is the renderer for this invocation, honouring the config default
// and then the flags.
func (f *Factory) Printer() *output.Printer {
jsonOut := f.JSONOut
if !jsonOut {
if cfg, err := f.Config(); err == nil && cfg.Get("output") == "json" {
jsonOut = true
}
}
return &output.Printer{IO: f.IO, JSON: jsonOut, Template: f.Template, Fields: f.Fields}
}
// ConfirmSend is the gate in front of anything that puts real mail on the
// wire. Without a terminal it refuses rather than sending, because a script
// that forgot --yes must not discover the omission by mailing strangers.
func (f *Factory) ConfirmSend(what string) error {
if f.AssumeYes {
return nil
}
if !f.IO.IsStdinTTY() {
return fmt.Errorf("%s sends real mail, and there is no terminal to confirm on.\nPass --yes to go ahead.", what)
}
ok, err := f.IO.Confirm(f.IO.Yellow("! ")+what+" sends real mail. Continue?", false)
if err != nil {
return err
}
if !ok {
return errCancelled
}
return nil
}
// ConfirmMutation guards a destructive but non-sending change. It only asks
// when the user opted in with `warmbly config set confirm always`, because
// prompting on every write makes a CLI tiring to use.
func (f *Factory) ConfirmMutation(what string) error {
if f.AssumeYes {
return nil
}
cfg, err := f.Config()
if err != nil || cfg.Get("confirm") != "always" {
return nil
}
if !f.IO.IsStdinTTY() {
return nil
}
ok, cerr := f.IO.Confirm(what+"?", false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
return nil
}
// bodyFromArg turns a --input value into a request body: a JSON literal, `-`
// for stdin, or @path for a file, the conventions curl taught everyone.
func bodyFromArg(in io.Reader, data string) ([]byte, error) {
data = strings.TrimSpace(data)
if data == "" {
return nil, nil
}
switch {
case data == "-":
return io.ReadAll(in)
case strings.HasPrefix(data, "@"):
return os.ReadFile(strings.TrimPrefix(data, "@"))
default:
return []byte(data), nil
}
}
+101
View File
@@ -0,0 +1,101 @@
// warmbly is the command line interface to Warmbly.
//
// It is the customer's CLI, not the operator's: it signs in as a person, holds
// one credential per host in ~/.config/warmbly, and speaks only the public
// REST API, so it drives the hosted service and any self-hosted instance the
// caller can reach. Everything it can do is bounded by the scopes the sign-in
// approved, and it never serves HTTP.
//
// The other CLI, warmblyctl, is the operator's: it talks to Postgres directly,
// runs inside the backend container, and exists for recovery and accounts. If
// you are asking "what is wrong with this install", that is the one you want.
//
// warmbly auth login
// warmbly campaign list
// warmbly api "/campaigns?limit=10"
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
// errCancelled is a user saying no at a prompt. It exits 1 with no error line,
// because the person already knows what happened.
var errCancelled = errors.New("cancelled")
// errSilent lets a command print its own failure and still exit non-zero.
var errSilent = errors.New("")
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
f := NewFactory()
root := newRootCmd(f)
root.SetArgs(expandAliases(f, os.Args[1:]))
err := root.ExecuteContext(ctx)
// After the command, never before: the reminder is not worth delaying a
// result for, and it must not appear instead of an error.
if err == nil {
nudgeAboutUpdates(ctx, f)
}
if err != nil {
os.Exit(reportError(f.IO, err))
}
}
// reportError turns whatever came back into one line a person can act on, and
// the exit code a script can branch on:
//
// 1 the command failed
// 2 usage was wrong
// 4 not signed in, or the credential was rejected
func reportError(io *iostreams.IOStreams, err error) int {
if errors.Is(err, errCancelled) {
fmt.Fprintln(io.ErrOut, io.Gray("Cancelled."))
return 1
}
if errors.Is(err, errSilent) {
return 1
}
var noToken *config.ErrNoToken
if errors.As(err, &noToken) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), noToken.Error())
return 4
}
var apiErr *api.Error
if errors.As(err, &apiErr) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), apiErr.Error())
switch {
case apiErr.IsUnauthorized():
fmt.Fprintln(io.ErrOut, io.Gray("The credential was rejected. Run `warmbly auth status` to see which one was used, or `warmbly auth login` to replace it."))
return 4
case apiErr.Status == 403:
fmt.Fprintln(io.ErrOut, io.Gray("The key is missing a scope for this call. `warmbly auth refresh` re-runs the sign-in and can ask for more."))
return 4
}
return 1
}
var noTTY *iostreams.ErrNoTTY
if errors.As(err, &noTTY) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), noTTY.Error())
return 2
}
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), err.Error())
return 1
}
+338
View File
@@ -0,0 +1,338 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/output"
)
// The typed commands are a table. Dispatch, flags, help, the request and the
// rendering all come from one row, so covering a new endpoint is adding a row
// rather than writing a command. `warmbly api` covers anything the table does
// not, which is what keeps the table from having to be exhaustive to be useful.
type bodyMode int
const (
bodyNone bodyMode = iota // the endpoint takes no body
bodyOptional // a body may be sent
bodyRequired // a body must be sent
)
type flagKind int
const (
flagString flagKind = iota
flagInt
flagBool
flagStrings
)
// argSpec is one positional argument, filling the next {} in the path.
type argSpec struct {
Name string
Help string
}
// flagSpec is one flag. Query flags become query parameters; the rest become
// body fields, so `campaign create --name X` needs no JSON.
type flagSpec struct {
Name string
Short string
Help string
Kind flagKind
Query bool
// Key overrides the body field or query parameter name, which otherwise
// is the flag name with dashes turned into underscores.
Key string
}
func (f flagSpec) key() string {
if f.Key != "" {
return f.Key
}
return strings.ReplaceAll(f.Name, "-", "_")
}
type endpoint struct {
Name string
Aliases []string
Short string
Long string
Example string
Method string
// Path is /v1-relative and carries one {name} per positional argument.
Path string
Args []argSpec
Flag []flagSpec
Body bodyMode
// Sends marks a command that puts real mail on the wire. Those confirm.
Sends bool
// Paginate offers --all, which walks the cursor.
Paginate bool
// Idempotent offers --idempotency-key.
Idempotent bool
Table output.Table
// Success is what to say on a terminal when there is nothing to tabulate.
Success string
}
type resource struct {
Name string
Aliases []string
Short string
Long string
Group string
Endpoints []endpoint
}
func resourceCommands(f *Factory) []*cobra.Command {
specs := resourceSpecs()
out := make([]*cobra.Command, 0, len(specs))
for _, r := range specs {
out = append(out, buildResource(f, r))
}
return out
}
func buildResource(f *Factory, r resource) *cobra.Command {
cmd := &cobra.Command{
Use: r.Name + " <command>",
Aliases: r.Aliases,
Short: r.Short,
Long: r.Long,
GroupID: r.Group,
}
for _, e := range r.Endpoints {
cmd.AddCommand(buildEndpoint(f, r, e))
}
return cmd
}
func buildEndpoint(f *Factory, r resource, e endpoint) *cobra.Command {
use := e.Name
for _, a := range e.Args {
use += " <" + a.Name + ">"
}
long := e.Long
if long == "" {
long = e.Short + "."
}
if len(e.Args) > 0 {
var lines []string
for _, a := range e.Args {
lines = append(lines, fmt.Sprintf(" <%s> %s", a.Name, a.Help))
}
long += "\n\nArguments:\n" + strings.Join(lines, "\n")
}
if e.Sends {
long += "\n\nThis command sends real mail. It asks before doing so; --yes skips the question."
}
cmd := &cobra.Command{
Use: use,
Aliases: e.Aliases,
Short: e.Short,
Long: long,
Example: e.Example,
Args: cobra.ExactArgs(len(e.Args)),
}
// Flag values are held here so the runner reads whatever cobra parsed.
strs := map[string]*string{}
ints := map[string]*int{}
bools := map[string]*bool{}
slices := map[string]*[]string{}
for _, fl := range e.Flag {
switch fl.Kind {
case flagInt:
ints[fl.Name] = cmd.Flags().IntP(fl.Name, fl.Short, 0, fl.Help)
case flagBool:
bools[fl.Name] = cmd.Flags().BoolP(fl.Name, fl.Short, false, fl.Help)
case flagStrings:
slices[fl.Name] = cmd.Flags().StringSliceP(fl.Name, fl.Short, nil, fl.Help)
default:
strs[fl.Name] = cmd.Flags().StringP(fl.Name, fl.Short, "", fl.Help)
}
}
var (
input string
rawField []string
typField []string
all bool
maxPages int
idemKey string
)
if e.Body != bodyNone {
cmd.Flags().StringVar(&input, "input", "", "Request body: JSON, @file, or - for stdin")
cmd.Flags().StringArrayVarP(&rawField, "raw-field", "f", nil, "Body field as a string: key=value")
cmd.Flags().StringArrayVarP(&typField, "field", "F", nil, "Body field with a guessed type: key=value")
}
if e.Paginate {
cmd.Flags().BoolVar(&all, "all", false, "Fetch every page, not just the first")
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Stop after this many pages when --all is set")
}
if e.Idempotent {
cmd.Flags().StringVar(&idemKey, "idempotency-key", "", "Idempotency-Key header for a safely retryable write")
}
cmd.RunE = func(c *cobra.Command, args []string) error {
path, err := fillPath(e.Path, e.Args, args)
if err != nil {
return err
}
query := url.Values{}
body := map[string]any{}
for _, fl := range e.Flag {
if !c.Flags().Changed(fl.Name) {
continue
}
var value any
switch fl.Kind {
case flagInt:
value = *ints[fl.Name]
case flagBool:
value = *bools[fl.Name]
case flagStrings:
value = *slices[fl.Name]
default:
value = *strs[fl.Name]
}
if fl.Query {
query.Set(fl.key(), queryString(value))
continue
}
if err := assign(body, fl.key(), value); err != nil {
return err
}
}
raw, err := bodyFromArg(f.IO.In, input)
if err != nil {
return err
}
fields, err := buildFields(rawField, typField, f.IO.In)
if err != nil {
return err
}
for k, v := range fields {
body[k] = v
}
var payload []byte
switch {
case raw != nil && len(body) > 0:
// Merging a literal body with flags would silently pick a winner.
return fmt.Errorf("pass --input or the field flags, not both")
case raw != nil:
if !json.Valid(raw) {
return fmt.Errorf("the request body is not valid JSON")
}
payload = raw
case len(body) > 0:
payload, err = json.Marshal(body)
if err != nil {
return err
}
case e.Body == bodyRequired:
return fmt.Errorf("%s %s needs a body.\nSupply one with the flags above, with -f key=value, or with --input @file.json", r.Name, e.Name)
case e.Body == bodyOptional && e.Method != http.MethodGet:
payload = []byte("{}")
}
if e.Sends {
if err := f.ConfirmSend(fmt.Sprintf("`warmbly %s %s`", r.Name, e.Name)); err != nil {
return err
}
} else if e.Method == http.MethodDelete {
if err := f.ConfirmMutation(fmt.Sprintf("Run `warmbly %s %s`", r.Name, e.Name)); err != nil {
return err
}
}
client, err := f.Client()
if err != nil {
return err
}
req := api.Request{
Method: e.Method,
Path: path,
Query: query,
Body: payload,
IdempotencyKey: idemKey,
}
printer := f.Printer()
if all {
merged, perr := client.Paginate(c.Context(), req, maxPages)
if perr != nil {
return perr
}
return printer.Print(merged, e.Table)
}
resp, err := client.Do(c.Context(), req)
if err != nil {
return err
}
// Nothing to tabulate and a terminal to talk to: say what happened
// rather than printing an empty object.
if len(e.Table.Columns) == 0 && e.Success != "" && !printer.JSON && printer.Template == "" && f.IO.IsStdoutTTY() {
f.IO.Printf("%s %s\n", f.IO.Tick(), e.Success)
return nil
}
return printer.Print(resp.Body, e.Table)
}
return cmd
}
// fillPath substitutes positional arguments into the {} markers, in order.
func fillPath(path string, specs []argSpec, args []string) (string, error) {
for i, spec := range specs {
marker := "{" + spec.Name + "}"
if !strings.Contains(path, marker) {
return "", fmt.Errorf("internal: path %q has no %s", path, marker)
}
value := strings.TrimSpace(args[i])
if value == "" {
return "", fmt.Errorf("<%s> cannot be empty", spec.Name)
}
path = strings.ReplaceAll(path, marker, url.PathEscape(value))
}
if strings.Contains(path, "{") {
return "", fmt.Errorf("internal: path %q still has an unfilled placeholder", path)
}
return path, nil
}
func queryString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
case int:
return fmt.Sprint(t)
case []string:
return strings.Join(t, ",")
default:
return fmt.Sprint(t)
}
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// Command groups, so `warmbly --help` reads as a product rather than an
// alphabetical dump of forty nouns.
const (
groupCore = "core"
groupWork = "work"
groupData = "data"
groupDevelop = "develop"
groupSetup = "setup"
)
func newRootCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "warmbly <command> <subcommand> [flags]",
Short: "Warmbly from the command line",
Long: `Work with Warmbly from your terminal.
Sign in once with ` + "`warmbly auth login`" + `, then drive campaigns, contacts,
mailboxes and the inbox as yourself. Everything the CLI can do is bounded by
the scopes you approved, on the hosted service or on your own instance.`,
Example: ` $ warmbly auth login
$ warmbly campaign list
$ warmbly mailbox list --json
$ warmbly inbox list --unseen
$ warmbly api "/campaigns?limit=10"`,
SilenceUsage: true,
SilenceErrors: true,
// A bare `warmbly` is a request for the help, not an error.
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 0 {
return c.Help()
}
return fmt.Errorf("unknown command %q. Run `warmbly --help` for the full list.", args[0])
},
}
p := cmd.PersistentFlags()
p.StringVar(&f.HostFlag, "host", "", "Signed-in host to use (default: the active one)")
p.BoolVar(&f.JSONOut, "json", false, "Print the API response as JSON")
p.StringVar(&f.Template, "template", "", "Format the response with a Go template")
p.StringSliceVar(&f.Fields, "fields", nil, "Table columns to keep, comma separated")
p.BoolVar(&f.AssumeYes, "yes", false, "Answer every prompt with yes, including sends")
p.BoolVar(&f.NoColor, "no-color", false, "Never colourise output")
p.BoolVar(&f.Debug, "debug", false, "Print each request to stderr")
cmd.PersistentPreRun = func(*cobra.Command, []string) {
if f.NoColor {
f.IO.SetColor(false)
}
}
cmd.AddGroup(
&cobra.Group{ID: groupCore, Title: "Getting started"},
&cobra.Group{ID: groupWork, Title: "Doing the work"},
&cobra.Group{ID: groupData, Title: "Your data"},
&cobra.Group{ID: groupDevelop, Title: "Building on Warmbly"},
&cobra.Group{ID: groupSetup, Title: "Setting up the CLI"},
)
cmd.AddCommand(newAuthCmd(f))
cmd.AddCommand(newStatusCmd(f))
cmd.AddCommand(newBrowseCmd(f))
cmd.AddCommand(newAPICmd(f))
cmd.AddCommand(newEventsCmd(f))
cmd.AddCommand(newConfigCmd(f))
cmd.AddCommand(newAliasCmd(f))
cmd.AddCommand(newVersionCmd(f))
cmd.AddCommand(newUpgradeCmd(f))
for _, rc := range resourceCommands(f) {
cmd.AddCommand(rc)
}
cmd.SetOut(f.IO.Out)
cmd.SetErr(f.IO.ErrOut)
// The generated completion command has its own group so it does not sit
// under "Getting started" pretending to be a first step.
cmd.SetHelpCommandGroupID(groupSetup)
cmd.SetCompletionCommandGroupID(groupSetup)
return cmd
}
// expandAliases rewrites the argument list when the first word is a user
// alias. Aliases are plain command lines, so `warmbly alias set hot 'campaign
// list --status active'` makes `warmbly hot --json` work.
func expandAliases(f *Factory, args []string) []string {
if len(args) == 0 || strings.HasPrefix(args[0], "-") {
return args
}
cfg, err := f.Config()
if err != nil || len(cfg.Aliases) == 0 {
return args
}
expansion, ok := cfg.Aliases[args[0]]
if !ok {
return args
}
parts, err := splitArgs(expansion)
if err != nil || len(parts) == 0 {
return args
}
return append(parts, args[1:]...)
}
// splitArgs is shell-ish word splitting: quotes group, nothing else is
// special. An alias is a command line, not a shell script.
func splitArgs(in string) ([]string, error) {
var out []string
var cur strings.Builder
var quote rune
started := false
for _, r := range in {
switch {
case quote != 0:
if r == quote {
quote = 0
continue
}
cur.WriteRune(r)
case r == '\'' || r == '"':
quote = r
started = true
case r == ' ' || r == '\t':
if started || cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteRune(r)
}
}
if quote != 0 {
return nil, fmt.Errorf("unbalanced quote in %q", in)
}
if started || cur.Len() > 0 {
out = append(out, cur.String())
}
return out, nil
}
+1925
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/iostreams"
"github.com/warmbly/warmbly/internal/models"
)
// `warmbly status` is the one screen answer to "what is happening in my
// workspace right now". It is several calls composed into one view, and every
// section degrades on its own: a key without analytics scope still gets the
// mailbox and inbox lines rather than one failure for the whole command.
func newStatusCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "What is happening in your workspace right now",
GroupID: groupCore,
Long: `A single screen: who you are, which mailboxes need attention, what is
sending today, and what is waiting for a reply.
Sections you have no scope for are skipped rather than failing the command.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runStatus(c.Context(), f)
},
}
}
func runStatus(ctx context.Context, f *Factory) error {
io := f.IO
client, err := f.Client()
if err != nil {
return err
}
// --json is one document rather than a rendered screen, so a script gets
// the same information without parsing prose.
bundle := map[string]any{}
collect := func(key, path string, query url.Values) json.RawMessage {
resp, derr := client.Do(ctx, api.Request{Method: http.MethodGet, Path: path, Query: query})
if derr != nil {
if f.Debug {
io.Errorf("* %s: %v\n", path, derr)
}
return nil
}
var parsed any
if json.Unmarshal(resp.Body, &parsed) == nil {
bundle[key] = parsed
}
return resp.Body
}
identity := collect("me", "/me", nil)
mailboxes := collect("mailboxes", "/emails", url.Values{"limit": []string{"100"}})
campaigns := collect("campaigns", "/campaigns", url.Values{"limit": []string{"100"}})
unread := collect("inbox", "/unibox/count", nil)
dashboard := collect("analytics", "/analytics/dashboard", nil)
if f.JSONOut || !io.IsStdoutTTY() {
raw, merr := json.MarshalIndent(bundle, "", " ")
if merr != nil {
return merr
}
io.Println(string(raw))
return nil
}
printIdentity(io, identity)
printMailboxes(io, mailboxes)
printCampaigns(io, campaigns)
printInbox(io, unread, dashboard)
if len(bundle) == 0 {
return fmt.Errorf("nothing could be read with this credential. Run `warmbly auth status` to check it.")
}
return nil
}
func printIdentity(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var id struct {
Email string `json:"email"`
OrganizationName string `json:"organization_name"`
}
if json.Unmarshal(raw, &id) != nil {
return
}
line := io.Bold(id.Email)
if id.OrganizationName != "" {
line += io.Gray(" in ") + io.Bold(id.OrganizationName)
}
io.Printf("%s\n\n", line)
}
func printMailboxes(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var list struct {
Data []struct {
Email string `json:"email"`
Status string `json:"status"`
AuthState string `json:"auth_state"`
CampaignLimit int `json:"campaign_limit"`
Warmup *string `json:"warmup"`
} `json:"data"`
}
if json.Unmarshal(raw, &list) != nil {
return
}
total, warming, capacity, unchecked := len(list.Data), 0, 0, 0
var trouble []string
for _, m := range list.Data {
capacity += m.CampaignLimit
if m.Warmup != nil {
warming++
}
// "unknown" means never checked and never gates sending, so it is not
// a problem to report; only a failing check is.
switch {
case m.Status != "" && m.Status != "active":
trouble = append(trouble, fmt.Sprintf("%s %s", m.Email, io.Red(m.Status)))
case m.AuthState == models.AuthStateFailing:
trouble = append(trouble, fmt.Sprintf("%s %s", m.Email, io.Yellow("SPF, DKIM or DMARC failing")))
case m.AuthState == models.AuthStateUnknown:
unchecked++
}
}
io.Printf("%s\n", io.Gray("MAILBOXES"))
if total == 0 {
io.Printf(" none connected. `warmbly browse mailboxes` opens the dashboard.\n\n")
return
}
io.Printf(" %d connected, %d warming, %d emails/day of campaign capacity\n", total, warming, capacity)
for _, t := range trouble {
io.Printf(" %s %s\n", io.Cross(), t)
}
if len(trouble) == 0 {
io.Printf(" %s all healthy\n", io.Tick())
}
if unchecked > 0 {
io.Printf(" %s\n", io.Gray(fmt.Sprintf("%d never had their authentication checked: `warmbly mailbox recheck <id>`", unchecked)))
}
io.Println()
}
func printCampaigns(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var list struct {
Data []struct {
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if json.Unmarshal(raw, &list) != nil {
return
}
counts := map[string]int{}
var active []string
for _, c := range list.Data {
counts[c.Status]++
if c.Status == "active" || c.Status == "running" {
active = append(active, c.Name)
}
}
io.Printf("%s\n", io.Gray("CAMPAIGNS"))
if len(list.Data) == 0 {
io.Printf(" none yet. `warmbly campaign create --name \"My campaign\"` starts one.\n\n")
return
}
var parts []string
for _, status := range []string{"active", "paused", "draft", "completed"} {
if n := counts[status]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, status))
}
}
io.Printf(" %s\n", strings.Join(parts, ", "))
for i, name := range active {
if i == 5 {
io.Printf(" %s\n", io.Gray(fmt.Sprintf("and %d more sending", len(active)-5)))
break
}
io.Printf(" %s %s\n", io.Green("▸"), name)
}
io.Println()
}
func printInbox(io *iostreams.IOStreams, unread, dashboard json.RawMessage) {
io.Printf("%s\n", io.Gray("INBOX"))
if unread != nil {
var count struct {
Count int `json:"count"`
Total int `json:"total"`
}
if json.Unmarshal(unread, &count) == nil {
n := count.Count
if n == 0 {
n = count.Total
}
if n > 0 {
io.Printf(" %s unread\n", io.Bold(fmt.Sprint(n)))
} else {
io.Printf(" %s nothing unread\n", io.Tick())
}
}
}
if dashboard != nil {
var stats map[string]any
if json.Unmarshal(dashboard, &stats) == nil {
var parts []string
for _, key := range []string{"sent", "opened", "clicked", "replied", "bounced"} {
if v, ok := stats[key]; ok {
parts = append(parts, fmt.Sprintf("%v %s", v, key))
}
}
if len(parts) > 0 {
io.Printf("\n%s\n %s\n", io.Gray("RECENT ACTIVITY"), strings.Join(parts, ", "))
}
}
}
io.Println()
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/update"
"github.com/warmbly/warmbly/internal/version"
)
func newUpgradeCmd(f *Factory) *cobra.Command {
var check bool
cmd := &cobra.Command{
Use: "upgrade",
Aliases: []string{"update", "self-update"},
Short: "Update the CLI to the newest release",
GroupID: groupSetup,
Long: `Replace this binary with the newest published release.
When the CLI came from a package manager it says which command to run instead,
because overwriting a file Homebrew or Scoop owns produces a version that
reverts on their next upgrade.`,
Example: ` $ warmbly upgrade
$ warmbly upgrade --check`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runUpgrade(c.Context(), f, check)
},
}
cmd.Flags().BoolVar(&check, "check", false, "Only report whether a newer release exists")
return cmd
}
func runUpgrade(ctx context.Context, f *Factory, checkOnly bool) error {
io := f.IO
current := version.String()
io.Errorf("%s\n", io.Gray("Checking for a newer release"))
latest, err := update.LatestVersion(ctx, 15*time.Second)
if err != nil {
return fmt.Errorf("could not reach the release feed: %w", err)
}
// The state file is refreshed here too, so an explicit check silences the
// automatic reminder for the next day.
state := config.LoadState()
state.LastUpdateCheck = time.Now().UTC()
state.LatestVersion = latest
_ = state.Save()
if !update.IsNewer(current, latest) {
if current == latest {
io.Printf("%s warmbly %s is the newest release\n", io.Tick(), io.Bold(current))
} else {
// A dev build is not behind, it is simply not one of ours.
io.Printf("%s running %s; the newest release is %s\n", io.Tick(), io.Bold(current), io.Bold(latest))
}
return nil
}
io.Printf("%s %s is available (you have %s)\n", io.Yellow("↑"), io.Bold(latest), current)
if checkOnly {
return nil
}
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("could not find this binary on disk: %w", err)
}
if cmd := update.DetectMethod(executable).UpgradeCommand(); cmd != "" {
io.Println()
io.Printf("This CLI was installed with a package manager. Upgrade it with:\n\n %s\n", io.Bold(cmd))
return nil
}
if !f.AssumeYes && io.IsStdinTTY() {
ok, cerr := io.Confirm(fmt.Sprintf("Replace %s with %s?", executable, latest), true)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
if err := update.Replace(ctx, executable, func(step string) {
io.Errorf("%s %s\n", io.Gray("…"), step)
}); err != nil {
return err
}
io.Printf("%s Upgraded to %s\n", io.Tick(), io.Bold(latest))
return nil
}
// nudgeAboutUpdates prints a one-line reminder after a command has finished,
// at most once a day, and only when someone is watching.
//
// It runs after the command's own output so it never delays a result, and
// every failure is swallowed: a version check is not worth an error message,
// and an air-gapped machine must not pay for one on every run.
func nudgeAboutUpdates(ctx context.Context, f *Factory) {
if !f.IO.IsStdoutTTY() || !f.IO.IsStdinTTY() {
return
}
if f.JSONOut || os.Getenv("WARMBLY_NO_UPDATE_CHECK") != "" || os.Getenv("CI") != "" {
return
}
state := config.LoadState()
if latest := state.LatestVersion; latest != "" && update.IsNewer(version.String(), latest) {
f.IO.Errorf("\n%s %s is available. Run %s\n",
f.IO.Yellow("↑"), f.IO.Bold(latest), f.IO.Bold("warmbly upgrade"))
}
if time.Since(state.LastUpdateCheck) < update.CheckInterval {
return
}
// Two seconds is the whole budget: this happens after the user already has
// what they asked for, and a slow network must not make the CLI feel slow.
latest, err := update.LatestVersion(ctx, 2*time.Second)
if err != nil {
return
}
state.LastUpdateCheck = time.Now().UTC()
state.LatestVersion = latest
_ = state.Save()
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"encoding/json"
"runtime"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/version"
)
func newVersionCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the CLI's version",
GroupID: groupSetup,
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
info := version.Current()
if f.JSONOut {
payload, err := json.MarshalIndent(map[string]string{
"version": info.Version,
"commit": info.Commit,
"built": info.BuiltAt,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}, "", " ")
if err != nil {
return err
}
f.IO.Println(string(payload))
return nil
}
f.IO.Printf("warmbly %s (%s/%s)\n", info.Version, runtime.GOOS, runtime.GOARCH)
if c := version.ShortCommit(); c != "" {
f.IO.Printf("commit %s\n", c)
}
if info.BuiltAt != "" {
f.IO.Printf("built %s\n", info.BuiltAt)
}
return nil
},
}
}
+6 -1
View File
@@ -32,7 +32,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=$CGO GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags "$TAGS" -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/backend ./cmd/backend; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/seed ./cmd/seed; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/migrate ./cmd/migrate; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmblyctl ./cmd/warmblyctl
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmblyctl ./cmd/warmblyctl; \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w -X github.com/warmbly/warmbly/internal/version.Version=$VERSION -X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT -X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" -o /out/warmbly ./cmd/cli
# Runtime stage
FROM alpine:3.23
@@ -60,6 +61,10 @@ COPY --from=builder /out/migrate /app/migrate
# `docker compose exec backend warmblyctl status` and not a path.
COPY --from=builder /out/warmblyctl /usr/local/bin/warmblyctl
# The customer CLI ships alongside it, so an operator who has exec on the box
# can drive the product as well as recover it without installing anything.
COPY --from=builder /out/warmbly /usr/local/bin/warmbly
# Installer script the worker orchestrator uploads + runs over SSH, and serves
# at GET /worker-install.sh. The mode is explicit because COPY otherwise keeps
# the checkout's: on a filesystem without POSIX permissions that is 0700, and
+46
View File
@@ -0,0 +1,46 @@
# The `warmbly` CLI as an image, for CI jobs and anywhere installing a binary
# is more trouble than pulling one:
#
# docker run --rm -e WARMBLY_TOKEN ghcr.io/warmbly/warmbly/cli campaign list
#
# Distroless-style: the CLI is a static binary that talks to one HTTPS API, so
# the runtime needs certificates, timezone data and nothing else.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=""
ARG COMMIT=""
ARG BUILT_AT=""
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags="-s -w \
-X github.com/warmbly/warmbly/internal/version.Version=$VERSION \
-X github.com/warmbly/warmbly/internal/version.Commit=$COMMIT \
-X github.com/warmbly/warmbly/internal/version.BuiltAt=$BUILT_AT" \
-o /out/warmbly ./cmd/cli
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 1000 warmbly
COPY --from=builder /out/warmbly /usr/local/bin/warmbly
# A container has no browser, so `warmbly auth login` cannot finish here.
# WARMBLY_TOKEN is the documented way in, and the config directory is a volume
# mount point for anyone who would rather bring their hosts.yml.
ENV WARMBLY_CONFIG_DIR=/home/warmbly/.config/warmbly \
WARMBLY_NO_UPDATE_CHECK=1
USER warmbly
WORKDIR /home/warmbly
ENTRYPOINT ["warmbly"]
CMD ["--help"]
+22
View File
@@ -17,6 +17,28 @@ The `wmbly_` prefix identifies the key as a Warmbly key. The remaining 43 charac
Keys are stored as a SHA-256 hash; the plaintext is shown exactly once on creation. To help you spot a key in the dashboard without exposing the secret, we store the first 8 characters (`key_prefix`, e.g. `wmbly_ab`) and the last 4 characters (`key_suffix`, e.g. `wxyz`). Render them as `wmbly_ab…wxyz`.
## Three ways to get a key
1. **The dashboard.** Settings > API keys, pick the scopes, copy the secret. This is the right path for a key a server will use.
2. **The CLI.** [`warmbly auth login`](/api/cli/) opens a browser approval and mints a key named for the machine that asked, then stores it at 0600. This is the right path for a key you will use yourself, and it is the only one that does not involve pasting a secret into a shell.
3. **The API.** `POST /v1/api-keys` with a key that carries `API_KEYS`. The secret is in that response and nowhere else.
All three produce the same thing: a `wmbly_` key with a scope bitmask, listed under Settings > API keys, revocable there.
### The CLI device flow
`warmbly auth login` uses a device-code handshake, so the terminal never handles your password and the browser never handles the key:
1. The CLI calls `POST /v1/auth/cli/code` with the scopes it wants and the machine's hostname. It gets back a `device_code` it keeps, a `user_code` it prints, and a `verification_uri_complete` it opens.
2. You approve at `app.warmbly.com/cli`, choosing which workspace the key belongs to. The approval is what mints the key, so it requires the `MANAGE_API_KEYS` organization permission.
3. The CLI polls `POST /v1/auth/cli/poll` and receives the key exactly once, on the first poll after approval.
Codes expire after ten minutes, both halves are per-IP rate limited, and the `device_code` is stored hashed. Anything else can drive the same flow: it is two public endpoints and a browser.
### Ending a key
`DELETE /v1/api-keys/:id` revokes any key in the workspace and needs the `API_KEYS` scope. `DELETE /v1/api-keys/self` revokes the key the call was made with and needs no scope at all, so a narrowly scoped credential can always end itself. This is what `warmbly auth logout` uses.
## Using your API key
Include your API key in the `Authorization` header of every request:
+382
View File
@@ -0,0 +1,382 @@
---
title: CLI
description: The warmbly command line interface. Sign in once, then drive campaigns, contacts, mailboxes and the inbox from your terminal, from CI, or from an agent.
---
`warmbly` is the command line interface to Warmbly. It signs in as you, holds one credential per host, and speaks only the public REST API, so it works against the hosted service and against any self-hosted instance you can reach.
```bash
warmbly auth login
warmbly campaign list
warmbly inbox list --unseen
warmbly api "/campaigns?limit=10"
```
<Callout type="info" title="This is not warmblyctl">
There are two CLIs and they answer different questions. `warmbly` is the one you install on your machine to use the product. [`warmblyctl`](/development/warmblyctl/) is the operator's tool: it talks to Postgres directly, runs inside the backend container, and exists for recovery, accounts, health and backups. If you are asking "what is wrong with this install", that is the one you want.
</Callout>
## Install
Pick one. All of them produce the same single binary.
**macOS and Linux**
```bash
curl -fsSL https://warmbly.com/cli.sh | sh
```
Installs to `~/.local/bin`, so it needs no root and no toolchain. The script verifies what it downloaded against the published checksum and installs nothing if they disagree, writes shell completions, and tells you the one line to add to your profile if that directory is not already on your PATH.
**Windows**
```powershell
irm https://warmbly.com/cli.ps1 | iex
```
Installs to `%LOCALAPPDATA%\Warmbly\bin` and adds it to your user PATH. No admin rights.
**Homebrew** (macOS and Linux)
```bash
brew install warmbly/tap/warmbly
```
**Scoop** (Windows)
```powershell
scoop bucket add warmbly https://github.com/warmbly/homebrew-tap
scoop install warmbly
```
**Docker**, for CI or anywhere installing a binary is more trouble than pulling one:
```bash
docker run --rm -e WARMBLY_TOKEN ghcr.io/warmbly/warmbly/cli campaign list
```
A container has no browser, so sign in with `WARMBLY_TOKEN` rather than `auth login`.
**From source**, if you have Go:
```bash
go install github.com/warmbly/warmbly/cmd/cli@latest
mv "$(go env GOPATH)/bin/cli" "$(go env GOPATH)/bin/warmbly"
```
The package directory is `cmd/cli`, so `go install` names the binary `cli`. Rename it, or use one of the channels above.
**Already have a Warmbly instance?** The binary ships inside the backend image:
```bash
docker compose -p warmbly exec backend warmbly --help
```
### Keeping it current
```bash
warmbly version
warmbly upgrade
```
`upgrade` replaces the binary in place, verifying the download against the release checksums first. When the CLI came from Homebrew or Scoop it says which command to run instead, rather than overwriting a file a package manager owns. The CLI also checks for a new release once a day and mentions it in one line; `WARMBLY_NO_UPDATE_CHECK=1` turns that off, and it never runs in CI or when output is piped.
### Installer flags
The install script takes flags after `--`, and every one of them is also an environment variable, so the same install runs from Ansible, cloud-init or a Dockerfile:
```bash
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --dir /usr/local/bin
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --version v1.4.0
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --no-modify-path --no-completions
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --dry-run
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --uninstall
```
| Flag | Variable | What it does |
|---|---|---|
| `--dir PATH` | `WARMBLY_INSTALL_DIR` | Where the binary goes. Default `~/.local/bin` |
| `--version TAG` | `WARMBLY_CLI_VERSION` | Pin a release instead of taking the newest |
| `--base-url URL` | `WARMBLY_CLI_BASE_URL` | Download from an internal mirror of the release assets |
| `--no-modify-path` | `WARMBLY_NO_MODIFY_PATH` | Never touch a shell profile |
| `--no-completions` | `WARMBLY_NO_COMPLETIONS` | Skip the completion files |
| `--dry-run` | | Print what would happen, change nothing |
| `--uninstall` | | Remove the binary and completions, keep your sign-ins |
`--help` lists them in the terminal. `sh -s -- --help` works through the pipe.
### Reading it before you run it
Piping a script into a shell is worth being careful about, which is why the checksum is published next to it:
```bash
curl -fsSLO https://warmbly.com/cli.sh
curl -fsSLO https://warmbly.com/cli.sh.sha256
sha256sum -c cli.sh.sha256
less cli.sh && sh cli.sh
```
Every release archive is covered by `checksums.txt` on the release, and the installer verifies the archive against it before unpacking. If the two disagree it installs nothing and says so.
### Completions
The installer writes them for your shell. To do it by hand:
```bash
warmbly completion bash > /etc/bash_completion.d/warmbly
warmbly completion zsh > "${fpath[1]}/_warmbly"
warmbly completion fish > ~/.config/fish/completions/warmbly.fish
```
## Signing in
```bash
warmbly auth login
```
It asks two questions: which instance (the hosted service, or a hostname of your own), and how (a browser approval, or pasting a key you already have).
The browser path shows an eight character code, opens `app.warmbly.com/cli`, and waits. You approve there, choosing which workspace the CLI is signing in to. The approval creates **one API key named for your machine**, which appears under Settings > API keys and is revocable there or with `warmbly auth logout`. The terminal never handles your password, and the browser never handles the key.
```
Your code: K4TM-9RQD
Approve at: https://app.warmbly.com/cli?code=K4TM-9RQD
… Waiting for approval (the code expires in 10 minutes)
✓ Signed in to warmbly.com as jane@example.com
Workspace Acme
```
Non-interactive forms, for a script or an agent:
```bash
warmbly auth login --hostname warmbly.acme.com --web
echo "$WARMBLY_KEY" | warmbly auth login --with-token
warmbly auth login --scopes read-only
```
### Scopes
The CLI asks for full access by default, and the approval screen lists exactly what that means before you agree. Narrow it with `--scopes`:
```bash
warmbly auth login --scopes read-only
warmbly auth login --scopes read_campaigns,read_contacts,send_campaigns
```
A key's scopes are fixed once created, so widening them means a new key. `warmbly auth refresh --scopes full` runs the sign-in again and revokes the key it replaces, which is why refreshing does not leave a trail of keys behind.
Scope names are the ones in the [permissions reference](/api/permissions/), in either case, with `full` and `read-only` as shorthands.
### Several instances
The CLI holds one credential per host. The `*` in `auth status` is the one commands use.
```bash
warmbly auth login --hostname warmbly.acme.com
warmbly auth status
warmbly auth switch warmbly.acme.com
warmbly campaign list --host warmbly.com # one command, other host
```
`warmbly auth status` also tells you where the token came from, which is the answer nine times out of ten when a command fails unexpectedly:
```
* warmbly.com
✓ signed in as jane@example.com
- workspace: Acme
- scopes: all 24
- api: https://api.warmbly.com
- token from: /home/jane/.config/warmbly/hosts.yml
- token: wmbly_ab********wxyz
```
### In CI
Set `WARMBLY_TOKEN` and skip the login entirely. It overrides the file and is never written to it.
```yaml
env:
WARMBLY_TOKEN: ${{ secrets.WARMBLY_TOKEN }}
WARMBLY_HOST: warmbly.com # omit for the hosted service
run: warmbly campaign list --json
```
`warmbly auth token` prints the active token and nothing else, for handing to another tool.
## Output
Commands print a table on a terminal and JSON everywhere else, so the same command is readable by a person and parseable by a pipe.
```bash
warmbly campaign list # a table
warmbly campaign list > campaigns.json # JSON, no flag needed
warmbly campaign list --json | jq '.data[].name'
warmbly campaign list --fields name,status
warmbly campaign list --template '{{range .data}}{{.name}}{{"\n"}}{{end}}'
```
`--all` walks the cursor on any list command and merges every page into one response.
## Sending real mail
Anything that puts mail on the wire asks first, and refuses rather than sending when there is no terminal to ask on:
```
$ warmbly campaign start 6f1c…
! `warmbly campaign start` sends real mail. Continue? [y/N]
```
`--yes` is the only way past it, which makes it the flag to grep for in a script review. The commands that behave this way are `campaign start`, `campaign test`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft`.
## Commands
Run `warmbly <command> --help` for the flags, and `warmbly <command> <subcommand> --help` for one command's arguments.
| Command | What it covers |
|---|---|
| `auth` | login, logout, status, token, switch, refresh |
| `status` | one screen: mailboxes needing attention, what is sending, what is unread |
| `browse` | open the dashboard, or one record, in a browser |
| `campaign` | list, view, create, edit, steps, senders, segments, preflight, test, start, stop, logs |
| `contact` | list, view, create, edit, delete, lookup, timeline, notes, import, export, verify |
| `mailbox` | list, view, edit, health checks, sync state, sending behaviour, warmup, hold, send |
| `inbox` | list, view, threads, read, reply, compose, drafts, scheduled sends, snoozes |
| `suppression` | the addresses and domains that get no campaign mail |
| `segment` | live audiences and their conditions |
| `template` | reply templates |
| `automation` | automations, their runs and test firing |
| `form` | lead capture forms, submissions and stats |
| `deal`, `pipeline`, `task` | the CRM |
| `analytics` | dashboard, deliverability, warmup, per-mailbox and per-campaign numbers |
| `audit` | the workspace's audit trail |
| `advisor` | recommendations, and applying or dismissing them |
| `webhook` | endpoints, deliveries, redelivery, event types |
| `key` | API keys, their scopes and their usage |
| `oauth-app` | OAuth applications you publish |
| `integration` | third-party connections |
| `org` | which workspace this credential belongs to |
| `team` | named groups of members, for CRM ownership and routing |
| `settings` | workspace-wide outreach and suppression settings |
| `warmup-routing` | which mailboxes warm with which |
| `tool` | the AI tool registry, listed and called |
| `events` | the live event stream |
| `api` | any endpoint at all |
| `upgrade` | replace this binary with the newest release |
| `config`, `alias`, `completion`, `version` | the CLI itself |
### Examples
```bash
# Create a campaign, add a step, check it, start it
warmbly campaign create --name "Q3 outbound" --daily-limit 40
warmbly campaign add-step CAMPAIGN_ID --subject "Quick question" --wait-after 0
warmbly campaign preflight CAMPAIGN_ID
warmbly campaign start CAMPAIGN_ID
# Mailbox health across the workspace
warmbly mailbox list
warmbly mailbox check MAILBOX_ID
warmbly mailbox edit MAILBOX_ID --daily-limit 40
# The inbox
warmbly inbox list --unseen --limit 20
warmbly inbox thread --email-id EMAIL_ID
# Contacts in and out
warmbly contact create --email jane@example.com --first-name Jane --company Acme
warmbly contact list --all --json > contacts.json
```
## What needs the dashboard
Three things the CLI deliberately does not do, because the API does not let a key do them:
- **Connecting a mailbox.** It needs OAuth consent or a credential form in a browser. `warmbly browse mailboxes` opens the right page.
- **Workspace administration.** Members, roles, invitations, workspace exports and the danger zone are session-only on the API: they depend on a human-bound session and refuse an API key. `warmbly browse members` and `warmbly browse settings` open them.
- **Billing.** Plans, checkout and credits are session-only for the same reason. `warmbly browse billing`.
`warmbly org view` still shows which workspace you are in, and `warmbly team`, `warmbly settings` and `warmbly audit` cover the workspace surface a key can reach.
## Watching events
`warmbly events tail` streams the developer WebSocket into your terminal: the same events the dashboard runs on, printed as they happen. It is the fastest way to see whether an integration is receiving what you think it is, without standing up a public endpoint first.
```bash
warmbly events tail
warmbly events tail --intent EMAIL --intent CAMPAIGN
warmbly events tail --json | jq 'select(.event_type == "EMAIL_REPLIED")'
```
It needs a key with `REALTIME_SUBSCRIBE`; `warmbly auth refresh --scopes full` gets one. The stream, its intents and its event types are documented under [Realtime](/api/realtime/).
## Calling the API directly
`warmbly api` reaches every endpoint, including the ones with no command of their own. Paths are relative to `/v1`.
```bash
warmbly api /me
warmbly api "/campaigns?limit=10" --paginate
warmbly api /contacts -f email=jane@example.com -f first_name=Jane
warmbly api /campaigns/CAMPAIGN_ID -X PATCH -F daily_limit=40
warmbly api /contacts/search -X POST --input filter.json
warmbly api /webhooks/WEBHOOK_ID -X DELETE
```
`-f` keeps a value a string. `-F` guesses the type, so `true`, `false`, `null` and numbers arrive as themselves, `@file` reads a value from a file, `key[sub]=v` nests and repeated `key[]=v` builds an array. `--paginate` follows the cursor, `-i` includes the status and headers, `--idempotency-key` rides the [documented header](/api/authentication/).
## Configuration
Two files under `~/.config/warmbly` (or `XDG_CONFIG_HOME`, or `WARMBLY_CONFIG_DIR`):
- `hosts.yml`, one credential per host, written 0600
- `config.yml`, preferences and aliases
Signing in records the instance's own API and dashboard URLs alongside the credential, taken from what the instance reports, so `browse` and `events tail` work on a self-hosted layout without anyone configuring a second address.
```bash
warmbly config list
warmbly config set output json # default to JSON even on a terminal
warmbly config set confirm always # confirm every write, not only sends
warmbly config set browser firefox
```
### Aliases
```bash
warmbly alias set hot "campaign list --status active"
warmbly hot --json
```
Anything you type after the alias is appended, so an alias is a starting point rather than a fixed command.
### Environment
| Variable | What it does |
|---|---|
| `WARMBLY_TOKEN` | The API key to use. Overrides `hosts.yml` and is never written to it |
| `WARMBLY_API_KEY` | The same thing under the name `warmblyctl` uses |
| `WARMBLY_HOST` | Which signed-in host to use |
| `WARMBLY_API_URL` | The API base URL, when it is not derivable from the host |
| `WARMBLY_CONFIG_DIR` | Where the two files live |
| `WARMBLY_NO_UPDATE_CHECK` | Never check for a newer release |
| `NO_COLOR` | Turns colour off, as everywhere else |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | It worked |
| `1` | The command failed, or you declined a prompt |
| `2` | The command line was wrong, or an answer was needed with no terminal to ask on |
| `4` | Not signed in, or the credential was rejected or lacks a scope |
Every API failure prints the response's machine-readable `code` and `request_id` to stderr, so a script can branch without reading prose.
## See also
- [Authentication](/api/authentication/) for how the device flow mints a key
- [Permissions](/api/permissions/) for what each scope allows
- [Endpoint scope map](/api/endpoints/) for what `warmbly api` can reach
- [Realtime](/api/realtime/) for the stream behind `warmbly events tail`
- [warmblyctl](/development/warmblyctl/) for the operator's CLI
+7
View File
@@ -266,6 +266,9 @@ Changing Advisor settings (`PATCH /advisor/settings`) is JWT only, alongside the
| GET | `/api-keys/:id` | `API_KEYS` |
| PATCH | `/api-keys/:id` | `API_KEYS` |
| DELETE | `/api-keys/:id` | `API_KEYS` |
| DELETE | `/api-keys/self` | none |
`DELETE /api-keys/self` revokes the key the request was made with, and is the one route here that needs no scope. A credential must always be able to end itself: requiring `API_KEYS` to sign out would leave a read-only key on a laptop someone is handing back live, which is what [`warmbly auth logout`](/api/cli/) promises to prevent. A JWT caller gets a `400`: there is no key in that request to end, only a session, which `POST /auth/logout` ends.
### OAuth apps
@@ -339,6 +342,8 @@ These never accept an API key. They depend on a human-bound session: billing flo
`GET /auth/config` gained two fields: `invites_required` (boolean, true when an invitation token is needed to create an account) and `docs_url` (string, the deployment's link to the accounts and access documentation, for a client to surface next to a refusal).
`GET /auth/config` also carries `websocket_url` and `app_url` (both strings, each omitted when the instance has none). They are the realtime gateway a developer client connects to and the dashboard origin a client sends someone to. Both are served here because on a self-hosted instance the host layout is whatever the operator chose, and there is no other way to discover it: the [CLI](/api/cli/) reads them for `warmbly events tail` and `warmbly browse`.
`GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe.
- `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists)
- `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in)
@@ -359,6 +364,7 @@ These never accept an API key. They depend on a human-bound session: billing flo
- All of `/organization/*` (create, switch, members, invitations, transfer ownership, avatar, danger zone)
- `GET /website-tracking/settings`, `PATCH /website-tracking/settings`, `POST /website-tracking/settings/rotate-key` (the [website tracking](/guides/website-tracking/) snippet's consent mode, location precision, allowed hosts and retention; JWT permission `MANAGE_SETTINGS`. The rotate is bodyless and safe to repeat, each call issues a new key)
- All of `/subscription/*` (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, referrals, etc.)
- All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `warmbly auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and is session-only: an API key must not be able to mint another one this way)
- All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, and on a self-hosted instance the connect flow and mailbox enrollment). `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`
- All of `/admin/*`
@@ -437,6 +443,7 @@ External MCP servers whose tools the assistant can use (see [Connect MCP tools](
## Public
- `GET /health`
- `POST /auth/cli/code`, `POST /auth/cli/poll` (the [CLI](/api/cli/) device-code handshake, per-IP rate limited. Public by necessity: the CLI has no credential until the flow completes. `POST /auth/cli/code` returns `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in` and `interval`; polling returns `{"status":"pending"}` until a member decides, then `{"status":"approved"}` carrying the minted key exactly once, or `{"status":"denied"}`. An unknown or expired `device_code` is a `404`, so a poller cannot probe for live handshakes)
- `POST /webhooks/github/releases` (HMAC-SHA256 signature)
- `POST /webhook/stripe` (Stripe signature)
- `POST /webhook/campaign`, `/webhook/email`, `/webhook/user-email` (Google OIDC token from Cloud Tasks)
+1
View File
@@ -6,6 +6,7 @@
"index",
"authentication",
"sdks",
"cli",
"oauth",
"permissions",
"endpoints",
@@ -141,6 +141,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
| `DISABLE_PASSWORD_LOGIN` | Turns off email and password entirely, for single sign-on only deployments | `false` | yes |
| `SSO_AUTO_PROVISION` | `true` lets a verified identity provider assertion create an account regardless of `DISABLE_REGISTRATION` | `false` | yes |
| `AUTH_IP_RATE_LIMIT` | Unauthenticated auth requests allowed per source IP per 15 minutes | `60` | yes |
| `CLI_AUTH_IP_RATE_LIMIT` | CLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one `warmbly auth login` polls around 200 times and must not exhaust the allowance above | `500` | yes |
| `WARMBLY_BOOTSTRAP_EMAIL` | First owner's address, read only while the users table is empty | unset | yes |
| `WARMBLY_BOOTSTRAP_PASSWORD_HASH` | Argon2 PHC string for that owner. Preferred over the plaintext form | unset | yes |
| `WARMBLY_BOOTSTRAP_PASSWORD` | Plaintext convenience form. Warns at boot, and leaves a password in your process environment | unset | yes |
+9 -1
View File
@@ -3,6 +3,12 @@ title: warmblyctl
description: The CLI for a Warmbly instance. The operator commands for accounts, health and recovery, and the API commands that let scripts and AI agents drive campaigns, contacts, mailboxes and the inbox with an API key.
---
<Callout type="info" title="There are two CLIs. This is the operator's one.">
`warmblyctl` is for running an instance: accounts, health, recovery, backups. It reads the database directly and is meant to be run inside the backend container.
If you want to use the product from your terminal (campaigns, contacts, mailboxes, the inbox), you want [`warmbly`](/api/cli/): a signed-in, multi-host client you install on your own machine, with `warmbly auth login` instead of an exported key.
</Callout>
`warmblyctl` is the CLI for a Warmbly instance, and it has two halves with two trust models.
The operator commands answer two questions: what state is this install in, and how do I get back in. They read and write the database directly, so they keep working when the sign-in page does not. Their authorization is container or host access, the same trust model as Sentry's `createuser`, Gitea's `admin user create` and authentik's `ak changepassword`, and it is the right one when the identity system is the thing that is broken.
@@ -11,6 +17,8 @@ The [API commands](#the-api-commands) drive a running instance over its public R
The CLI never serves HTTP. The API commands are a client of the already-gated public API, which adds no new surface to your instance.
Those API commands predate the [`warmbly` CLI](/api/cli/) and still work exactly as documented below. For day-to-day product work prefer `warmbly`: it signs in for you, holds a credential per host, prints tables rather than raw JSON, and runs on your laptop rather than inside a container.
## Running it
The binary ships inside the backend image at `/usr/local/bin/warmblyctl`, so it is on the path in every runtime. Running it inside the backend is the documented path because the environment there is already correct.
@@ -462,7 +470,7 @@ Paths are relative to `/v1`. `--data` takes a JSON literal, `-` for stdin, or `@
### For AI agents
The repository ships skills under `skills/` (`warmbly-api` for the product, `warmbly-ops` for instance administration, `warmbly-install` for standing an instance up and moving it) that teach a coding agent these commands, the conventions above, and the sending-safety rules. Install them the way your agent expects, for example `cp -r skills/warmbly-api ~/.claude/skills/` for Claude Code, or point the agent at the `SKILL.md` directly. An agent given a scoped key and those skills can operate a workspace end to end without touching the database.
The repository ships skills under `skills/` (`warmbly-cli` for the [`warmbly` CLI](/api/cli/), `warmbly-api` for the same product surface through `warmblyctl`, `warmbly-ops` for instance administration, `warmbly-install` for standing an instance up and moving it) that teach a coding agent these commands, the conventions above, and the sending-safety rules. Install them the way your agent expects, for example `cp -r skills/warmbly-api ~/.claude/skills/` for Claude Code, or point the agent at the `SKILL.md` directly. An agent given a scoped key and those skills can operate a workspace end to end without touching the database.
## When Redis is down
+4 -2
View File
@@ -25,6 +25,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/golangci/golangci-lint v1.64.8
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/invopop/jsonschema v0.13.0
github.com/jackc/pgx/v5 v5.9.0
github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32
@@ -36,16 +37,19 @@ require (
github.com/oschwald/geoip2-golang/v2 v2.0.0
github.com/redis/go-redis/v9 v9.11.0
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.9.1
github.com/stripe/stripe-go/v76 v76.25.0
github.com/xuri/excelize/v2 v2.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.45.0
google.golang.org/api v0.260.0
google.golang.org/grpc v1.82.1
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -261,7 +265,6 @@ require (
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
@@ -326,7 +329,6 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
+31
View File
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// RevokeOwnAPIKey revokes the key the request was made with.
//
// Deliberately outside the API_KEYS scope gate: a credential must always be
// able to end itself. Requiring a privilege to sign out means a read-only key
// on a laptop someone is handing back stays live, which is the opposite of
// what a `warmbly auth logout` promises.
func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
keyID := middleware.GetAPIKeyID(c)
if keyID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
reason := c.Query("reason")
if reason == "" {
reason = "Revoked by the credential itself"
}
if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// ListAPIPermissions lists all available API permissions
// GET /api-keys/permissions
func (h *Handler) ListAPIPermissions(c *gin.Context) {
+14
View File
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
// DocsURL is where to send someone whose signup was refused by deployment
// policy rather than by anything they did wrong.
DocsURL string `json:"docs_url"`
// WebsocketURL is the realtime gateway. Served here because a developer
// client (the CLI's event stream, an SDK) has no other way to find the
// socket on a self-hosted instance. Empty when the instance runs no
// realtime service.
WebsocketURL string `json:"websocket_url,omitempty"`
// AppURL is the dashboard origin, the same one every emailed link is built
// from. A client that wants to send someone to a page (the CLI's `browse`,
// a chat integration) cannot derive it: on a self-hosted instance the host
// layout is whatever the operator chose.
AppURL string `json:"app_url,omitempty"`
}
// accountsDocsURL is the page every registration refusal points at.
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
WebsocketURL: config.WebsocketURL(),
AppURL: config.AppBaseURL(),
})
}
+126
View File
@@ -0,0 +1,126 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
// what the CLI calls; the session half is the browser approval screen.
func (h *Handler) cliAuthReady(c *gin.Context) bool {
if h.CLIAuthService == nil {
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
return false
}
return true
}
// CLIAuthStart opens a handshake for a CLI that holds no key yet.
func (h *Handler) CLIAuthStart(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req models.CLIAuthStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusCreated, res)
}
// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
// handed out exactly once, on the poll that follows approval.
func (h *Handler) CLIAuthPoll(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req struct {
DeviceCode string `json:"device_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
return
}
res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, res)
}
// CLIAuthDescribeCode shows the approving member what they are authorizing.
func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, code)
}
// CLIAuthApproveCode mints the key into the workspace named in the body, not
// the session's, because a member with several workspaces picks on the screen.
func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req models.CLIAuthApproveRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
orgID, perr := uuid.Parse(req.OrganizationID)
if perr != nil {
if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
orgID = *sessionOrg
} else {
errx.JSON(c, errx.ErrNoOrganization)
return
}
}
code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// Logged against the org that was picked on screen, which is not always the
// session's, so this cannot go through auditOrg.
h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
c.JSON(http.StatusOK, code)
}
// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
// created, and a denial is not a change to the workspace.
func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+4
View File
@@ -14,6 +14,7 @@ import (
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -312,6 +313,9 @@ type Handler struct {
PoolLinkService poollink.Service
CloudLinkService cloudlink.Service
// Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
CLIAuthService cliauth.Service
// Infrastructure liveness probes for the admin System Status page.
// Wired in cmd/backend/main.go where the concrete clients live.
SystemChecker *sysstatus.Checker
+49 -6
View File
@@ -16,8 +16,27 @@ const (
// password, and far below what credential stuffing needs.
authIPWindow = 15 * time.Minute
authIPDefaultLimit = 60
// The CLI sign-in handshake gets its own budget, on its own key.
//
// It cannot share the auth one: `warmbly auth login` polls every
// CLIAuthPollIntervalSeconds for up to CLIAuthCodeTTLMinutes, which is
// around 200 requests for a single sign-in. On the shared budget that
// exhausts the allowance in three minutes, and then blocks the person's
// actual login from the same address for the rest of the window. The
// allowance below covers two concurrent sign-ins from one NAT with slack.
cliAuthIPWindow = 15 * time.Minute
cliAuthIPDefaultLimit = 500
)
// CLIAuthIPRateLimitMiddleware throttles the public CLI sign-in handshake per
// source IP, on a key of its own so a long poll cannot lock the same address
// out of signing in through the browser.
func (h *Handler) CLIAuthIPRateLimitMiddleware() gin.HandlerFunc {
return h.ipRateLimiter("cli_auth_ip:", cliAuthIPDefaultLimit, "CLI_AUTH_IP_RATE_LIMIT", cliAuthIPWindow,
"Too many CLI sign-in requests from this address. Try again later.")
}
// AuthIPRateLimitMiddleware throttles the public /auth group per source IP.
//
// This is the only limiter those routes have. RateLimitMiddleware keys on the
@@ -29,8 +48,15 @@ const (
// Fails open on a cache error, deliberately: a Redis blip must not lock every
// user out of their own instance.
func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
limit := authIPDefaultLimit
if v := os.Getenv("AUTH_IP_RATE_LIMIT"); v != "" {
return h.ipRateLimiter("auth_ip:", authIPDefaultLimit, "AUTH_IP_RATE_LIMIT", authIPWindow,
"Too many authentication attempts from this address. Try again later.")
}
// ipRateLimiter is the shared fixed-window limiter behind both. Each caller
// brings its own Redis key prefix, so budgets never bleed into each other.
func (h *Handler) ipRateLimiter(prefix string, defaultLimit int, env string, window time.Duration, message string) gin.HandlerFunc {
limit := defaultLimit
if v := os.Getenv(env); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
@@ -53,21 +79,38 @@ func (h *Handler) AuthIPRateLimitMiddleware() gin.HandlerFunc {
return
}
key := "auth_ip:" + ip
key := prefix + ip
n, err := h.Cache.Incr(c.Request.Context(), key).Result()
if err != nil {
c.Next()
return
}
// A counter with no TTL never resets, so the address it belongs to
// stays blocked forever once it passes the limit. That is a worse
// outcome than not counting at all, so a failed EXPIRE drops the key
// and lets the request through, matching how the rest of this
// middleware handles a cache it cannot trust.
if n == 1 {
_ = h.Cache.Expire(c.Request.Context(), key, authIPWindow).Err()
if err := h.Cache.Expire(c.Request.Context(), key, window).Err(); err != nil {
_ = h.Cache.Del(c.Request.Context(), key).Err()
c.Next()
return
}
} else if n > int64(limit) {
// Repair a key that lost its expiry some other way (an older
// build, a restore, an eviction between the INCR and the EXPIRE
// above). Only on the reject path, which is rare, so it costs a
// round trip nobody feels.
if ttl, terr := h.Cache.TTL(c.Request.Context(), key).Result(); terr == nil && ttl < 0 {
_ = h.Cache.Expire(c.Request.Context(), key, window).Err()
}
}
if n > int64(limit) {
c.Header("Retry-After", fmt.Sprintf("%d", int(authIPWindow.Seconds())))
c.Header("Retry-After", fmt.Sprintf("%d", int(window.Seconds())))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "rate_limit_exceeded",
"message": "Too many authentication attempts from this address. Try again later.",
"message": message,
"code": "rate_limit_exceeded",
})
c.Abort()
+29
View File
@@ -240,6 +240,18 @@ func Run(
poolLinkPublic.POST("/poll", h.PoolLinkPoll)
}
// `warmbly auth login`. Unauthenticated by nature (the CLI has no key yet),
// so it is throttled per source IP, but on its OWN budget: one sign-in
// polls around 200 times, which would exhaust the auth allowance and then
// lock the same address out of the browser login for the rest of the
// window.
cliAuthPublic := v1.Group("/auth/cli")
cliAuthPublic.Use(m.CLIAuthIPRateLimitMiddleware())
{
cliAuthPublic.POST("/code", h.CLIAuthStart)
cliAuthPublic.POST("/poll", h.CLIAuthPoll)
}
auth := v1.Group("/auth")
// Every unauthenticated auth route shares one per-IP budget. Nothing
// throttled these before: RateLimitMiddleware is keyed on the user id and
@@ -754,6 +766,11 @@ func Run(
// API key management. JWT users need PermManageAPIKeys; API keys
// need the APIPermAPIKeys self-service bit. This lets an integration
// rotate its own keys without going through the dashboard.
// Self-revocation, outside the API_KEYS gate below on purpose: any
// valid key may end itself, which is what makes signing a machine
// out actually end its access.
protected.DELETE("/api-keys/self", m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite), h.RevokeOwnAPIKey)
apiKeys := protected.Group("/api-keys")
apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys))
apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite))
@@ -1223,6 +1240,18 @@ func Run(
poolLink.GET("/instances", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkListInstances)
poolLink.DELETE("/instances/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkRevokeInstance)
}
// Browser half of `warmbly auth login`: a member reviews the code
// and approves it into one of their workspaces. Session-only, like
// the pool link approval, because approving mints a credential and
// an API key must not be able to mint another CLI's key.
cliAuth := jwtOnly.Group("/auth/cli")
cliAuth.Use(m.RateLimitMiddleware(models.RateLimitWrite))
{
cliAuth.GET("/codes/:code", h.CLIAuthDescribeCode)
cliAuth.POST("/codes/:code/approve", h.CLIAuthApproveCode)
cliAuth.POST("/codes/:code/deny", h.CLIAuthDenyCode)
}
// The linked instance's own surface, authenticated by its token.
poolLinkInstance := base.Group("/pool-link/instance")
poolLinkInstance.Use(m.PoolLinkAuthMiddleware())
+286
View File
@@ -0,0 +1,286 @@
// Package cliauth is the device-code sign-in the `warmbly` CLI uses.
//
// The CLI has no credential of its own, so it opens a handshake, shows the
// user an eight character code, and polls. A signed-in member approves the
// code in the browser, and the approval mints an ordinary API key through the
// existing service: same hash, same scopes, same revocation, visible under
// Settings > API keys like every other key. Nothing here is a new credential
// type and nothing here is a new authentication path.
package cliauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/user"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
var (
ErrCodeNotFound = errx.NewWithIdentifier(errx.NotFound, "cli_auth_code_not_found", "That code is unknown or has expired. Run `warmbly auth login` again for a fresh one.")
ErrCodeNotPending = errx.NewWithIdentifier(errx.Conflict, "cli_auth_code_used", "That code has already been used.")
ErrBadRequest = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_request", "device_code is required.")
ErrBadScopes = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_scopes", "The requested scopes include bits this instance does not grant.")
ErrForbidden = errx.NewWithIdentifier(errx.Forbidden, "cli_auth_forbidden", "Managing API keys is required to authorize a CLI in this workspace.")
)
type Service interface {
// StartCode opens a handshake for a CLI that holds no key yet.
StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error)
// PollCode is what the CLI calls until a member decides.
PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error)
// DescribeCode is what the approving member sees before deciding.
DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error)
// ApproveCode mints the key into the named workspace.
ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error)
DenyCode(ctx context.Context, userCode string) *errx.Error
}
type service struct {
repo repository.CLIAuthRepository
keys apikey.APIKeyService
orgs organization.OrganizationService
users user.UserService
orgRep repository.OrganizationRepository
}
func NewService(
repo repository.CLIAuthRepository,
keys apikey.APIKeyService,
orgs organization.OrganizationService,
users user.UserService,
orgRep repository.OrganizationRepository,
) Service {
return &service{repo: repo, keys: keys, orgs: orgs, users: users, orgRep: orgRep}
}
// Unambiguous alphabet: no 0/O, 1/I/L. Same as the pool link handshake, because
// both codes get read off one screen and typed into another.
const userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func randomUserCode() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
out := make([]byte, 0, 9)
for i, v := range b {
if i == 4 {
out = append(out, '-')
}
out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
}
return string(out), nil
}
func randomDeviceCode() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func hashDeviceCode(t string) string {
sum := sha256.Sum256([]byte(t))
return hex.EncodeToString(sum[:])
}
// NormalizeUserCode accepts any case, with or without the dash, so a user who
// retypes the code by hand is not punished for the formatting.
func NormalizeUserCode(raw string) string {
raw = strings.ToUpper(strings.TrimSpace(raw))
raw = strings.ReplaceAll(raw, "-", "")
raw = strings.ReplaceAll(raw, " ", "")
if len(raw) != 8 {
return raw
}
return raw[:4] + "-" + raw[4:]
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n]
}
return s
}
func (s *service) StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error) {
req.ClientName = clip(req.ClientName, 60)
if req.ClientName == "" {
req.ClientName = "Warmbly CLI"
}
req.Hostname = clip(req.Hostname, 80)
req.CLIVersion = clip(req.CLIVersion, 40)
// An unknown bit would grant a scope the approval screen never showed.
if req.Scopes&^models.AllAPIPermissionsMask != 0 {
return nil, ErrBadScopes
}
if req.Scopes == 0 {
req.Scopes = models.APIPermFullAccess
}
deviceCode, err := randomDeviceCode()
if err != nil {
return nil, errx.InternalError()
}
_ = s.repo.DeleteExpiredCodes(ctx)
var code *models.CLIAuthCode
for attempt := 0; attempt < 3; attempt++ {
userCode, uerr := randomUserCode()
if uerr != nil {
return nil, errx.InternalError()
}
created, cerr := s.repo.CreateCode(ctx, hashDeviceCode(deviceCode), userCode, req, time.Now().Add(config.CLIAuthCodeTTLMinutes*time.Minute))
if cerr == nil && created != nil {
code = created
break
}
// A user-code collision is the only expected failure; retry with a new one.
}
if code == nil {
return nil, errx.InternalError()
}
verify := config.AppBaseURL() + "/cli"
return &models.CLIAuthStartResponse{
DeviceCode: deviceCode,
UserCode: code.UserCode,
VerificationURL: verify,
VerificationURLComplete: verify + "?code=" + url.QueryEscape(code.UserCode),
ExpiresIn: config.CLIAuthCodeTTLMinutes * 60,
Interval: config.CLIAuthPollIntervalSeconds,
}, nil
}
func (s *service) PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error) {
deviceCode = strings.TrimSpace(deviceCode)
if deviceCode == "" {
return nil, ErrBadRequest
}
code, secret, err := s.repo.ClaimCode(ctx, hashDeviceCode(deviceCode))
if err != nil {
return nil, errx.InternalError()
}
if code == nil {
// Expired and unknown are the same answer on purpose: a poller that
// can tell them apart can probe for live handshakes.
return nil, ErrCodeNotFound
}
res := &models.CLIAuthPollResponse{Status: code.Status}
if secret == "" {
return res, nil
}
res.Token = secret
res.Scopes = code.Scopes
res.ScopeNames = code.ScopeNames
res.OrganizationID = code.OrganizationID
// Identity is a convenience for `warmbly auth status`, not part of the
// grant, so a lookup failure must not lose the user their token.
if key, kerr := s.keys.ValidateKey(ctx, secret); kerr == nil && key != nil {
res.APIKeyID = &key.ID
res.UserID = &key.UserID
if u, uerr := s.users.GetUser(ctx, key.UserID); uerr == nil && u != nil {
res.UserEmail = u.Email
res.UserName = strings.TrimSpace(u.FirstName + " " + u.LastName)
}
}
if code.OrganizationID != nil && s.orgRep != nil {
if org, oerr := s.orgRep.GetByID(ctx, *code.OrganizationID); oerr == nil && org != nil {
res.OrganizationName = org.Name
}
}
return res, nil
}
func (s *service) DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error) {
code, err := s.repo.GetCodeByUserCode(ctx, NormalizeUserCode(userCode))
if err != nil {
return nil, errx.InternalError()
}
if code == nil {
return nil, ErrCodeNotFound
}
return code, nil
}
func (s *service) ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error) {
userCode = NormalizeUserCode(userCode)
code, xerr := s.DescribeCode(ctx, userCode)
if xerr != nil {
return nil, xerr
}
if code.Status != models.CLIAuthCodePending {
return nil, ErrCodeNotPending
}
allowed, xerr := s.orgs.HasPermission(ctx, orgID, userID, models.PermManageAPIKeys)
if xerr != nil {
return nil, xerr
}
if !allowed {
return nil, ErrForbidden
}
// The key is named for the machine that asked, so Settings > API keys shows
// which laptop a key belongs to and revoking the right one is possible.
name := code.ClientName
if code.Hostname != "" {
name += " on " + code.Hostname
}
desc := fmt.Sprintf("Created by `warmbly auth login` for code %s", code.UserCode)
created, xerr := s.keys.Create(ctx, orgID, userID, &models.CreateAPIKey{
Name: clip(name, 255),
Description: &desc,
Permissions: code.Scopes,
})
if xerr != nil {
return nil, xerr
}
ok, err := s.repo.ApproveCode(ctx, userCode, orgID, userID, created.ID, created.Secret)
if err != nil {
return nil, errx.InternalError()
}
if !ok {
// Someone approved or denied between the read and the write. The key
// would otherwise be an orphan nobody asked for.
_ = s.keys.Revoke(ctx, orgID, created.ID, "cli authorization was resolved elsewhere")
return nil, ErrCodeNotPending
}
code.Status = models.CLIAuthCodeApproved
code.OrganizationID = &orgID
code.APIKeyID = &created.ID
return code, nil
}
func (s *service) DenyCode(ctx context.Context, userCode string) *errx.Error {
ok, err := s.repo.DenyCode(ctx, NormalizeUserCode(userCode))
if err != nil {
return errx.InternalError()
}
if !ok {
return ErrCodeNotFound
}
return nil
}
+1
View File
@@ -786,6 +786,7 @@ var ExcludedTables = map[string]string{
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
"cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
"pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.",
"pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.",
"cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.",
+348
View File
@@ -0,0 +1,348 @@
// Package api is the CLI's HTTP client for the public Warmbly REST API.
//
// It exists so every command speaks to the API the same way: one place that
// knows the /v1 prefix, the bearer header, the idempotency header, the error
// envelope and how to walk a cursor. Nothing here is Warmbly-specific beyond
// those; the typed commands are a table on top of it.
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client is one host's API surface.
type Client struct {
BaseURL string
Token string
UserAgent string
HTTP *http.Client
// Debug prints the request line to stderr, which is the first thing
// anyone wants when a call goes somewhere unexpected.
Debug io.Writer
}
func New(baseURL, token, userAgent string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
UserAgent: userAgent,
HTTP: &http.Client{Timeout: 120 * time.Second},
}
}
// Error is a non-2xx response. Every field comes from the API's stable
// envelope, so a script can branch on Code without reading the prose.
type Error struct {
Status int
Code string
Message string
RequestID string
RetryAfter string
Method string
Path string
Body string
}
func (e *Error) Error() string {
msg := e.Message
if msg == "" {
msg = strings.TrimSpace(e.Body)
}
if msg == "" {
msg = http.StatusText(e.Status)
}
out := fmt.Sprintf("%s %s failed (HTTP %d", e.Method, e.Path, e.Status)
if e.Code != "" {
out += " " + e.Code
}
out += "): " + msg
if e.RequestID != "" {
out += " (request " + e.RequestID + ")"
}
if e.Status == http.StatusTooManyRequests && e.RetryAfter != "" {
out += ". Rate limited; retry after " + e.RetryAfter + "s."
}
return out
}
// IsNotFound and IsUnauthorized are what command code branches on.
func (e *Error) IsNotFound() bool { return e.Status == http.StatusNotFound }
func (e *Error) IsUnauthorized() bool { return e.Status == http.StatusUnauthorized }
// StatusOf returns the HTTP status of an API error, or 0.
func StatusOf(err error) int {
var apiErr *Error
if errors.As(err, &apiErr) {
return apiErr.Status
}
return 0
}
// Request is one call. Path is relative to /v1 unless it already names a
// version, which is what makes `warmbly api get /campaigns` work.
type Request struct {
Method string
Path string
Query url.Values
Body []byte
Headers map[string]string
// IdempotencyKey rides the documented header, for retryable writes.
IdempotencyKey string
// Anonymous skips the bearer header. Only the sign-in handshake uses it:
// the CLI has no credential yet, which is the whole point of the flow.
Anonymous bool
}
// Response is a completed call. Body is the raw payload: JSON for every
// documented endpoint, but a few stream files, so it is not parsed here.
type Response struct {
Status int
Header http.Header
Body []byte
Request *Request
}
// NormalizePath applies the /v1 rule. Exported because `warmbly api` prints
// the path it is about to call.
func NormalizePath(path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasPrefix(path, "/v") && len(path) > 2 && path[2] >= '0' && path[2] <= '9' {
return path
}
return "/v1" + path
}
func (c *Client) Do(ctx context.Context, req Request) (*Response, error) {
if c.Token == "" && !req.Anonymous {
return nil, errors.New("no API token. Run `warmbly auth login`, or set WARMBLY_TOKEN.")
}
path := NormalizePath(req.Path)
full := c.BaseURL + path
if len(req.Query) > 0 {
full += "?" + req.Query.Encode()
}
var reader io.Reader
if len(req.Body) > 0 {
reader = bytes.NewReader(req.Body)
}
httpReq, err := http.NewRequestWithContext(ctx, strings.ToUpper(req.Method), full, reader)
if err != nil {
return nil, err
}
if !req.Anonymous {
httpReq.Header.Set("Authorization", "Bearer "+c.Token)
}
httpReq.Header.Set("Accept", "application/json")
if c.UserAgent != "" {
httpReq.Header.Set("User-Agent", c.UserAgent)
}
if len(req.Body) > 0 && httpReq.Header.Get("Content-Type") == "" {
httpReq.Header.Set("Content-Type", "application/json")
}
if req.IdempotencyKey != "" {
httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey)
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
if c.Debug != nil {
fmt.Fprintf(c.Debug, "* %s %s\n", httpReq.Method, full)
}
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("could not reach the API at %s: %w\nCheck the host with `warmbly auth status`, or set WARMBLY_API_URL.", c.BaseURL, err)
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, fmt.Errorf("reading the API response: %w", err)
}
if c.Debug != nil {
fmt.Fprintf(c.Debug, "* HTTP %d (%d bytes)\n", resp.StatusCode, len(payload))
}
out := &Response{Status: resp.StatusCode, Header: resp.Header, Body: payload, Request: &req}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return out, nil
}
apiErr := &Error{
Status: resp.StatusCode,
Method: strings.ToUpper(req.Method),
Path: path,
Body: string(payload),
RetryAfter: resp.Header.Get("Retry-After"),
}
var envelope struct {
Error string `json:"error"`
Message string `json:"message"`
Code string `json:"code"`
RequestID string `json:"request_id"`
}
if json.Unmarshal(payload, &envelope) == nil {
apiErr.Code = envelope.Code
apiErr.RequestID = envelope.RequestID
apiErr.Message = envelope.Message
if apiErr.Message == "" {
apiErr.Message = envelope.Error
}
}
return out, apiErr
}
// JSON runs a request and decodes the body into v.
func (c *Client) JSON(ctx context.Context, req Request, v any) error {
resp, err := c.Do(ctx, req)
if err != nil {
return err
}
if v == nil || len(bytes.TrimSpace(resp.Body)) == 0 {
return nil
}
if err := json.Unmarshal(resp.Body, v); err != nil {
return fmt.Errorf("the API returned something that is not JSON: %w", err)
}
return nil
}
// paginateRetries is how many rate-limited pages one walk will wait out
// before giving up. Three covers a walk that crosses a minute boundary or two;
// beyond that the budget is the problem, not the timing.
const paginateRetries = 3
// listEnvelope is the documented list shape: data plus pagination.
type listEnvelope struct {
Data json.RawMessage `json:"data"`
Pagination struct {
NextCursor *string `json:"next_cursor"`
HasMore bool `json:"has_more"`
} `json:"pagination"`
}
// Paginate walks every page of a list endpoint and returns one merged
// envelope: data holds every row, pagination reports no more pages. Endpoints
// that do not use the cursor envelope come back unchanged after one call.
func (c *Client) Paginate(ctx context.Context, req Request, maxPages int) ([]byte, error) {
if maxPages <= 0 {
maxPages = 100
}
var merged []json.RawMessage
// The cursor the walk stopped on, empty when it reached the end. It is
// what tells the caller a --max-pages cut the list short rather than the
// data running out.
nextCursor := ""
query := url.Values{}
for k, v := range req.Query {
query[k] = v
}
// Retries are budgeted across the whole walk, not per page: a limit that
// never clears has to end as an error rather than looping until maxPages.
retriesLeft := paginateRetries
for page := 0; page < maxPages; page++ {
req.Query = query
resp, err := c.Do(ctx, req)
if err != nil {
// A long walk will meet the per-key minute budget. The response
// says how long to wait, so waiting is strictly better than
// handing back a partial list the caller cannot tell from a
// complete one.
wait, ok := retryAfter(err)
if !ok || retriesLeft == 0 {
return nil, err
}
retriesLeft--
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
page--
continue
}
var env listEnvelope
if jerr := json.Unmarshal(resp.Body, &env); jerr != nil || env.Data == nil {
// Not a cursor list. One page is the whole answer.
if page == 0 {
return resp.Body, nil
}
break
}
var rows []json.RawMessage
if err := json.Unmarshal(env.Data, &rows); err != nil {
if page == 0 {
return resp.Body, nil
}
break
}
merged = append(merged, rows...)
if !env.Pagination.HasMore || env.Pagination.NextCursor == nil || *env.Pagination.NextCursor == "" {
nextCursor = ""
break
}
nextCursor = *env.Pagination.NextCursor
query = cloneValues(query)
query.Set("cursor", *env.Pagination.NextCursor)
}
envelope := struct {
Data []json.RawMessage `json:"data"`
Pagination struct {
Total int `json:"total"`
NextCursor *string `json:"next_cursor"`
HasMore bool `json:"has_more"`
} `json:"pagination"`
}{Data: merged}
if envelope.Data == nil {
envelope.Data = []json.RawMessage{}
}
envelope.Pagination.Total = len(merged)
if nextCursor != "" {
envelope.Pagination.HasMore = true
envelope.Pagination.NextCursor = &nextCursor
}
return json.Marshal(envelope)
}
// retryAfter reports how long a rate-limited response asked the caller to
// wait. The wait is capped so a hostile or misconfigured Retry-After cannot
// park the CLI for an hour.
func retryAfter(err error) (time.Duration, bool) {
var apiErr *Error
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusTooManyRequests {
return 0, false
}
seconds, perr := strconv.Atoi(strings.TrimSpace(apiErr.RetryAfter))
if perr != nil || seconds <= 0 {
seconds = 5
}
if seconds > 120 {
seconds = 120
}
return time.Duration(seconds) * time.Second, true
}
func cloneValues(in url.Values) url.Values {
out := url.Values{}
for k, v := range in {
out[k] = append([]string(nil), v...)
}
return out
}
+232
View File
@@ -0,0 +1,232 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalizePath(t *testing.T) {
cases := map[string]string{
"/campaigns": "/v1/campaigns",
"campaigns": "/v1/campaigns",
"/v1/campaigns": "/v1/campaigns",
"/v2/campaigns": "/v2/campaigns",
// "/verify" starts with /v but is not a version, so it must be
// prefixed rather than treated as v-something.
"/verify": "/v1/verify",
}
for in, want := range cases {
if got := NormalizePath(in); got != want {
t.Errorf("NormalizePath(%q) = %q, want %q", in, got, want)
}
}
}
func TestErrorEnvelopeSurvives(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"forbidden","message":"missing scope","code":"insufficient_scope","request_id":"req_123"}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
_, err := c.Do(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"})
var apiErr *Error
if !errors.As(err, &apiErr) {
t.Fatalf("got %T, want *api.Error", err)
}
if apiErr.Code != "insufficient_scope" || apiErr.RequestID != "req_123" {
t.Errorf("the machine-readable fields were lost: %+v", apiErr)
}
if !strings.Contains(apiErr.Error(), "req_123") {
t.Errorf("the request id must reach the message: %s", apiErr.Error())
}
if StatusOf(err) != http.StatusForbidden {
t.Errorf("StatusOf = %d", StatusOf(err))
}
}
func TestAnonymousRequestSendsNoBearer(t *testing.T) {
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{}`)
}))
defer srv.Close()
c := New(srv.URL, "", "test")
if _, err := c.Do(context.Background(), Request{Method: http.MethodPost, Path: "/auth/cli/code", Anonymous: true}); err != nil {
t.Fatalf("anonymous request failed: %v", err)
}
if sawAuth != "" {
t.Errorf("an anonymous request carried %q", sawAuth)
}
// A normal request with no token must fail before it reaches the network.
if _, err := c.Do(context.Background(), Request{Method: http.MethodGet, Path: "/me"}); err == nil {
t.Error("a request with no token should not be attempted")
}
}
func TestPaginateFollowsTheCursor(t *testing.T) {
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
switch cursor {
case "":
pages++
fmt.Fprint(w, `{"data":[{"id":"a"},{"id":"b"}],"pagination":{"has_more":true,"next_cursor":"c2"}}`)
case "c2":
pages++
fmt.Fprint(w, `{"data":[{"id":"c"}],"pagination":{"has_more":false,"next_cursor":null}}`)
default:
t.Errorf("unexpected cursor %q", cursor)
}
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 10)
if err != nil {
t.Fatalf("paginate: %v", err)
}
if pages != 2 {
t.Errorf("fetched %d pages, want 2", pages)
}
var doc struct {
Data []map[string]string `json:"data"`
Pagination struct {
HasMore bool `json:"has_more"`
} `json:"pagination"`
}
if err := json.Unmarshal(merged, &doc); err != nil {
t.Fatalf("merged payload is not JSON: %v", err)
}
if len(doc.Data) != 3 {
t.Errorf("merged %d rows, want 3", len(doc.Data))
}
if doc.Pagination.HasMore {
t.Error("the merged envelope must report no more pages")
}
}
// An endpoint that does not use the cursor envelope comes back untouched
// rather than being flattened into an empty list.
func TestPaginateLeavesNonListPayloadsAlone(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"count":7}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
out, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/unibox/count"}, 10)
if err != nil {
t.Fatalf("paginate: %v", err)
}
if !strings.Contains(string(out), `"count":7`) {
t.Errorf("payload was rewritten: %s", out)
}
}
// A long --all walk will meet the per-key minute budget. The client waits the
// Retry-After rather than returning a partial list the caller cannot
// distinguish from a complete one.
func TestPaginateWaitsOutARateLimit(t *testing.T) {
limited := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if limited {
limited = false
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, `{"code":"rate_limit_exceeded","message":"slow down"}`)
return
}
fmt.Fprint(w, `{"data":[{"id":"a"}],"pagination":{"has_more":false,"next_cursor":null}}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 5)
if err != nil {
t.Fatalf("paginate should have waited and retried: %v", err)
}
if !strings.Contains(string(merged), `"id":"a"`) {
t.Errorf("the retried page was lost: %s", merged)
}
}
// A rate limit that never clears must still end, rather than looping until
// max-pages with the caller none the wiser.
func TestPaginateGivesUpOnAPermanentRateLimit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, `{"code":"rate_limit_exceeded"}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
if _, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 2); err == nil {
t.Fatal("a permanent rate limit must surface as an error")
}
}
// A walk cut short by --max-pages has to say so. A caller that cannot tell a
// truncated list from a complete one will act on a partial answer.
func TestPaginateReportsTruncation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Always another page.
fmt.Fprint(w, `{"data":[{"id":"x"}],"pagination":{"has_more":true,"next_cursor":"more"}}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 2)
if err != nil {
t.Fatalf("paginate: %v", err)
}
var doc struct {
Data []map[string]string `json:"data"`
Pagination struct {
HasMore bool `json:"has_more"`
NextCursor *string `json:"next_cursor"`
Total int `json:"total"`
} `json:"pagination"`
}
if err := json.Unmarshal(merged, &doc); err != nil {
t.Fatalf("merged payload: %v", err)
}
if len(doc.Data) != 2 {
t.Errorf("collected %d rows over 2 pages, want 2", len(doc.Data))
}
if !doc.Pagination.HasMore {
t.Error("a walk stopped by max-pages must report has_more")
}
if doc.Pagination.NextCursor == nil || *doc.Pagination.NextCursor != "more" {
t.Errorf("the cursor to resume from was lost: %+v", doc.Pagination.NextCursor)
}
}
// And a walk that genuinely ran out still reports the end.
func TestPaginateReportsCompletion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[{"id":"x"}],"pagination":{"has_more":false,"next_cursor":null}}`)
}))
defer srv.Close()
c := New(srv.URL, "wmbly_x", "test")
merged, err := c.Paginate(context.Background(), Request{Method: http.MethodGet, Path: "/campaigns"}, 10)
if err != nil {
t.Fatalf("paginate: %v", err)
}
if !strings.Contains(string(merged), `"has_more":false`) || !strings.Contains(string(merged), `"next_cursor":null`) {
t.Errorf("a completed walk must report the end: %s", merged)
}
}
+245
View File
@@ -0,0 +1,245 @@
// Package config is where the `warmbly` CLI remembers who you are.
//
// Two files, the split gh made conventional: config.yml holds preferences and
// aliases and is safe to read; hosts.yml holds one credential per host and is
// written 0600. Environment variables override both and are never written
// back, so CI sets WARMBLY_TOKEN and never runs a login.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// DefaultHost is the hosted service. A bare `warmbly auth login` means this.
const DefaultHost = "warmbly.com"
// Env vars, all documented. TokenEnv is checked first; APIKeyEnv is the name
// warmblyctl already taught people and keeps working.
const (
DirEnv = "WARMBLY_CONFIG_DIR"
TokenEnv = "WARMBLY_TOKEN"
APIKeyEnv = "WARMBLY_API_KEY"
HostEnv = "WARMBLY_HOST"
APIURLEnv = "WARMBLY_API_URL"
)
// Host is one signed-in instance.
type Host struct {
// APIURL is the base the CLI calls, without a trailing slash and without
// the /v1 prefix. Resolved once at login so no later command has to guess.
APIURL string `yaml:"api_url"`
// AppURL is the dashboard origin, as the instance reports it. Stored at
// sign-in so `warmbly browse` opens the right page rather than guessing
// from the hostname, which is wrong on any non-default layout.
AppURL string `yaml:"app_url,omitempty"`
Token string `yaml:"token,omitempty"`
User string `yaml:"user,omitempty"`
UserID string `yaml:"user_id,omitempty"`
Organization string `yaml:"organization,omitempty"`
OrganizationID string `yaml:"organization_id,omitempty"`
Scopes []string `yaml:"scopes,omitempty"`
// APIKeyID is what `warmbly auth logout` revokes.
APIKeyID string `yaml:"api_key_id,omitempty"`
AddedAt time.Time `yaml:"added_at,omitempty"`
}
// Config is the preference file.
type Config struct {
// ActiveHost is what commands use when no --host is given.
ActiveHost string `yaml:"active_host,omitempty"`
// Output is the default renderer: table or json.
Output string `yaml:"output,omitempty"`
// Confirm is "always" or "sends". "sends" (the default) prompts only for
// commands that put real mail on the wire.
Confirm string `yaml:"confirm,omitempty"`
// Pager is the command long output is piped through, or "cat" to disable.
Pager string `yaml:"pager,omitempty"`
// Browser overrides the command used to open a URL.
Browser string `yaml:"browser,omitempty"`
Aliases map[string]string `yaml:"aliases,omitempty"`
}
// Keys are the settable config fields, with what each one does. `warmbly
// config set` refuses anything not in here so a typo is not silently stored.
var Keys = []struct {
Name, Help, Default string
}{
{"active_host", "Which signed-in host commands use by default", DefaultHost},
{"output", "Default output format: table or json", "table"},
{"confirm", "When to prompt before a command that sends: sends or always", "sends"},
{"pager", "Program to page long output through; cat disables paging", "$PAGER"},
{"browser", "Program used to open a URL", "$BROWSER"},
}
// Dir is where both files live: WARMBLY_CONFIG_DIR, then XDG, then ~/.config.
func Dir() string {
if v := strings.TrimSpace(os.Getenv(DirEnv)); v != "" {
return v
}
if v := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); v != "" {
return filepath.Join(v, "warmbly")
}
home, err := os.UserHomeDir()
if err != nil {
return ".warmbly"
}
return filepath.Join(home, ".config", "warmbly")
}
func configPath() string { return filepath.Join(Dir(), "config.yml") }
func hostsPath() string { return filepath.Join(Dir(), "hosts.yml") }
// Load reads config.yml. A missing file is an empty config, not an error: the
// CLI has to work on a machine that has never run it.
func Load() (*Config, error) {
c := &Config{}
raw, err := os.ReadFile(configPath())
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
return c, fmt.Errorf("reading %s: %w", configPath(), err)
}
if err := yaml.Unmarshal(raw, c); err != nil {
return c, fmt.Errorf("%s is not valid YAML: %w", configPath(), err)
}
return c, nil
}
func (c *Config) Save() error {
raw, err := yaml.Marshal(c)
if err != nil {
return err
}
return writeFile(configPath(), raw, 0o600)
}
// Get reads one settable key, falling back to its default.
func (c *Config) Get(key string) string {
switch key {
case "active_host":
return c.ActiveHost
case "output":
if c.Output == "" {
return "table"
}
return c.Output
case "confirm":
if c.Confirm == "" {
return "sends"
}
return c.Confirm
case "pager":
return c.Pager
case "browser":
return c.Browser
}
return ""
}
// Set writes one settable key. Values are validated here rather than at use,
// so a bad value is rejected while the user is still looking at it.
func (c *Config) Set(key, value string) error {
value = strings.TrimSpace(value)
switch key {
case "active_host":
c.ActiveHost = value
case "output":
if value != "table" && value != "json" {
return fmt.Errorf("output must be table or json, not %q", value)
}
c.Output = value
case "confirm":
if value != "sends" && value != "always" {
return fmt.Errorf("confirm must be sends or always, not %q", value)
}
c.Confirm = value
case "pager":
c.Pager = value
case "browser":
c.Browser = value
default:
return fmt.Errorf("unknown config key %q. Run `warmbly config list` for the settable keys.", key)
}
return nil
}
// Hosts is hosts.yml: host name to credential.
type Hosts map[string]*Host
func LoadHosts() (Hosts, error) {
h := Hosts{}
raw, err := os.ReadFile(hostsPath())
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return h, nil
}
return h, fmt.Errorf("reading %s: %w", hostsPath(), err)
}
if err := yaml.Unmarshal(raw, &h); err != nil {
return h, fmt.Errorf("%s is not valid YAML: %w", hostsPath(), err)
}
for name, entry := range h {
if entry == nil {
delete(h, name)
}
}
return h, nil
}
// SaveHosts writes the credential file at 0600. An empty map removes the file
// rather than leaving a stub, so `auth logout` leaves nothing behind.
func (h Hosts) Save() error {
if len(h) == 0 {
if err := os.Remove(hostsPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
raw, err := yaml.Marshal(map[string]*Host(h))
if err != nil {
return err
}
return writeFile(hostsPath(), raw, 0o600)
}
// Names returns the configured hosts in a stable order.
func (h Hosts) Names() []string {
out := make([]string, 0, len(h))
for name := range h {
out = append(out, name)
}
sort.Strings(out)
return out
}
func writeFile(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating %s: %w", filepath.Dir(path), err)
}
// Write-then-rename so an interrupted save cannot truncate a working file
// and lock someone out of their own CLI.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("writing %s: %w", path, err)
}
return os.Chmod(path, mode)
}
// HostsPath and ConfigPath are exported for `auth status`, which tells people
// where their credentials actually live.
func HostsPath() string { return hostsPath() }
func ConfigPath() string { return configPath() }
+180
View File
@@ -0,0 +1,180 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNormalizeHost(t *testing.T) {
cases := map[string]string{
"warmbly.com": "warmbly.com",
"WARMBLY.COM": "warmbly.com",
"https://app.warmbly.com": "warmbly.com",
"api.warmbly.com": "warmbly.com",
"https://api.acme.dev/": "acme.dev",
"warmbly.acme.com": "warmbly.acme.com",
"localhost:8080": "localhost:8080",
"": DefaultHost,
// A two-label host is not a subdomain of anything, so "api.dev" must
// not be stripped down to "dev".
"api.dev": "api.dev",
}
for in, want := range cases {
if got := NormalizeHost(in); got != want {
t.Errorf("NormalizeHost(%q) = %q, want %q", in, got, want)
}
}
}
func TestDefaultAPIURL(t *testing.T) {
cases := map[string]string{
"warmbly.com": "https://api.warmbly.com",
"warmbly.acme.com": "https://api.warmbly.acme.com",
"localhost:8080": "http://localhost:8080",
}
for in, want := range cases {
if got := DefaultAPIURL(in); got != want {
t.Errorf("DefaultAPIURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestCandidateAPIURLs(t *testing.T) {
got := CandidateAPIURLs("acme.dev")
want := []string{"https://api.acme.dev", "https://acme.dev", "https://acme.dev/api"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
// A host with a port is an exact address; inventing api.<host>:port would
// probe something that cannot exist.
if urls := CandidateAPIURLs("localhost:8080"); len(urls) != 1 || urls[0] != "http://localhost:8080" {
t.Errorf("port host candidates = %v", urls)
}
}
func TestResolvePrefersEnvironmentAndSaysSo(t *testing.T) {
t.Setenv(TokenEnv, "wmbly_from_env")
hosts := Hosts{"warmbly.com": {APIURL: "https://api.warmbly.com", Token: "wmbly_from_file"}}
r, err := Resolve(&Config{ActiveHost: "warmbly.com"}, hosts, "")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if r.Token != "wmbly_from_env" {
t.Errorf("token = %q, want the environment's", r.Token)
}
if r.Source != TokenEnv {
t.Errorf("source = %q, want %q so a surprising result is traceable", r.Source, TokenEnv)
}
}
func TestResolveWithNoCredential(t *testing.T) {
t.Setenv(TokenEnv, "")
t.Setenv(APIKeyEnv, "")
_, err := Resolve(&Config{}, Hosts{}, "")
var missing *ErrNoToken
if err == nil {
t.Fatal("expected an error when nothing is signed in")
}
if !asErrNoToken(err, &missing) {
t.Fatalf("got %T, want *ErrNoToken", err)
}
}
func asErrNoToken(err error, target **ErrNoToken) bool {
e, ok := err.(*ErrNoToken)
if ok {
*target = e
}
return ok
}
func TestResolveUsesTheOnlyHostWhenNoneIsActive(t *testing.T) {
t.Setenv(TokenEnv, "")
t.Setenv(APIKeyEnv, "")
hosts := Hosts{"warmbly.acme.com": {APIURL: "https://api.warmbly.acme.com", Token: "wmbly_x"}}
r, err := Resolve(&Config{}, hosts, "")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if r.Host != "warmbly.acme.com" {
t.Errorf("host = %q, want the only signed-in one", r.Host)
}
}
// The credential file must never be group or world readable.
func TestHostsSaveIsPrivate(t *testing.T) {
dir := t.TempDir()
t.Setenv(DirEnv, dir)
hosts := Hosts{"warmbly.com": {APIURL: "https://api.warmbly.com", Token: "wmbly_secret"}}
if err := hosts.Save(); err != nil {
t.Fatalf("save: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "hosts.yml"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("hosts.yml is %o, want 600", perm)
}
loaded, err := LoadHosts()
if err != nil {
t.Fatalf("load: %v", err)
}
if loaded["warmbly.com"].Token != "wmbly_secret" {
t.Errorf("round trip lost the token")
}
// Signing out of the last host leaves no file behind.
delete(loaded, "warmbly.com")
if err := loaded.Save(); err != nil {
t.Fatalf("save empty: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "hosts.yml")); !os.IsNotExist(err) {
t.Errorf("hosts.yml survived an empty save")
}
}
func TestConfigSetRejectsUnknownAndInvalid(t *testing.T) {
c := &Config{}
if err := c.Set("nonsense", "x"); err == nil {
t.Error("an unknown key must be rejected, not silently stored")
}
if err := c.Set("output", "yaml"); err == nil {
t.Error("an invalid output format must be rejected at set time")
}
if err := c.Set("output", "json"); err != nil {
t.Errorf("valid value rejected: %v", err)
}
if c.Get("confirm") != "sends" {
t.Errorf("confirm default = %q, want sends", c.Get("confirm"))
}
}
// Every request carries a bearer token, so only this machine may be reached
// over plaintext HTTP. A remote host that merely names a port must not be
// downgraded, and the two URL helpers must not disagree about it.
func TestRemoteHostWithAPortStaysHTTPS(t *testing.T) {
if got := DefaultAPIURL("acme.dev:8443"); got != "https://acme.dev:8443" {
t.Errorf("DefaultAPIURL = %q, want https", got)
}
if urls := CandidateAPIURLs("acme.dev:8443"); len(urls) != 1 || urls[0] != "https://acme.dev:8443" {
t.Errorf("CandidateAPIURLs = %v, want one https entry", urls)
}
for _, local := range []string{"localhost:8080", "127.0.0.1:8080", "localhost"} {
if got := DefaultAPIURL(local); got[:5] != "http:" {
t.Errorf("DefaultAPIURL(%q) = %q, want plaintext for the local machine", local, got)
}
}
// A host that merely starts with "localhost" is somebody else's domain.
if got := DefaultAPIURL("localhost.evil.example"); got[:6] != "https:" {
t.Errorf("DefaultAPIURL(localhost.evil.example) = %q, want https", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
package config
import (
"fmt"
"net/url"
"os"
"strings"
)
// Resolved is the answer to "who am I and where am I pointed", worked out once
// per invocation from flags, environment and the two files, in that order.
type Resolved struct {
Host string
APIURL string
Token string
// Source says where the token came from, because "it worked yesterday" is
// almost always an environment variable nobody remembers exporting.
Source string
Entry *Host
}
// ErrNoToken is returned when nothing is signed in. Callers turn it into the
// one message worth printing: how to sign in.
type ErrNoToken struct{ Host string }
func (e *ErrNoToken) Error() string {
return fmt.Sprintf("not signed in to %s.\nRun `warmbly auth login` to sign in, or set WARMBLY_TOKEN to an API key (wmbly_...).", e.Host)
}
// Resolve works out the active host and token. hostFlag is --host, empty when
// not given.
func Resolve(cfg *Config, hosts Hosts, hostFlag string) (*Resolved, error) {
host := strings.TrimSpace(hostFlag)
if host == "" {
host = strings.TrimSpace(os.Getenv(HostEnv))
}
if host == "" {
host = strings.TrimSpace(cfg.ActiveHost)
}
if host == "" {
// One signed-in host and no preference is not ambiguous.
if names := hosts.Names(); len(names) == 1 {
host = names[0]
}
}
if host == "" {
host = DefaultHost
}
host = NormalizeHost(host)
r := &Resolved{Host: host, Entry: hosts[host]}
if r.Entry != nil {
r.APIURL = r.Entry.APIURL
r.Token = r.Entry.Token
r.Source = HostsPath()
}
// The environment wins, and says so, so a surprising result is traceable.
if v := strings.TrimSpace(os.Getenv(TokenEnv)); v != "" {
r.Token, r.Source = v, TokenEnv
} else if v := strings.TrimSpace(os.Getenv(APIKeyEnv)); v != "" {
r.Token, r.Source = v, APIKeyEnv
}
if v := strings.TrimSpace(os.Getenv(APIURLEnv)); v != "" {
r.APIURL = strings.TrimRight(v, "/")
}
if r.APIURL == "" {
r.APIURL = DefaultAPIURL(host)
}
if r.Token == "" {
return r, &ErrNoToken{Host: host}
}
return r, nil
}
// NormalizeHost turns whatever someone typed into the key hosts.yml uses: a
// bare host, no scheme, no path, no trailing slash.
func NormalizeHost(raw string) string {
h := strings.TrimSpace(strings.ToLower(raw))
h = strings.TrimSuffix(h, "/")
if h == "" {
return DefaultHost
}
if strings.Contains(h, "://") {
if u, err := url.Parse(h); err == nil && u.Host != "" {
h = u.Host
}
}
// A bare "app.warmbly.com" or "api.warmbly.com" means the same account as
// "warmbly.com"; storing three entries for one instance helps nobody. The
// dot count keeps a real two-label host like "api.dev" intact.
for _, prefix := range []string{"app.", "api.", "www."} {
if strings.HasPrefix(h, prefix) && strings.Count(h, ".") > 1 {
return strings.TrimPrefix(h, prefix)
}
}
return h
}
// isLoopback reports whether a host is this machine. That is the only case
// where plaintext HTTP is acceptable, because every request carries a bearer
// token: a remote host that merely names a port must still get https.
func isLoopback(host string) bool {
name, _, found := strings.Cut(host, ":")
if !found {
name = host
}
return name == "localhost" || name == "127.0.0.1" || name == "[::1]" || name == "::1"
}
// DefaultAPIURL is the base URL to try first for a host. The hosted service is
// known; a self-hosted host follows the layout the installer writes.
func DefaultAPIURL(host string) string {
host = NormalizeHost(host)
if host == DefaultHost {
return "https://api." + DefaultHost
}
if isLoopback(host) {
return "http://" + host
}
// A host that names a port is already an exact address, so no subdomain is
// invented for it; the scheme stays https.
if strings.Contains(host, ":") {
return "https://" + host
}
return "https://api." + host
}
// CandidateAPIURLs are the bases `auth login` probes for a self-hosted host,
// most likely first. The installer's three shapes produce the first three.
func CandidateAPIURLs(host string) []string {
host = NormalizeHost(host)
if strings.Contains(host, "://") {
return []string{strings.TrimRight(host, "/")}
}
scheme := "https"
if isLoopback(host) {
scheme = "http"
}
// A host with a port is already an exact address; do not invent subdomains.
if strings.Contains(host, ":") {
return []string{scheme + "://" + host}
}
return []string{
scheme + "://api." + host,
scheme + "://" + host,
scheme + "://" + host + "/api",
}
}
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"errors"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// State is the CLI's own bookkeeping: nothing the user sets, nothing secret.
// Kept apart from config.yml so a hand-edited config is never fighting with
// something the tool rewrites on its own.
type State struct {
// LastUpdateCheck is when the release check last ran. It is what keeps the
// check to once a day rather than once a command.
LastUpdateCheck time.Time `yaml:"last_update_check,omitempty"`
// LatestVersion is the newest release seen, so the reminder can be printed
// without a network call on every run.
LatestVersion string `yaml:"latest_version,omitempty"`
}
func statePath() string { return filepath.Join(Dir(), "state.yml") }
// LoadState never fails in a way a caller has to handle: a missing or corrupt
// state file means "we know nothing", which is always a safe answer.
func LoadState() *State {
s := &State{}
raw, err := os.ReadFile(statePath())
if err != nil {
return s
}
if err := yaml.Unmarshal(raw, s); err != nil {
return &State{}
}
return s
}
// Save is best effort on purpose. This file holds a timestamp and a version
// string; a read-only home directory or a container with no writable HOME must
// cost the user a redundant version check, not a failed command.
func (s *State) Save() error {
raw, err := yaml.Marshal(s)
if err != nil {
return err
}
if err := writeFile(statePath(), raw, 0o600); err != nil && !errors.Is(err, os.ErrPermission) {
return err
}
return nil
}
// StatePath is exported for `warmbly config list`, which shows where every
// file the CLI owns lives.
func StatePath() string { return statePath() }
+327
View File
@@ -0,0 +1,327 @@
// Package iostreams is the CLI's terminal: where output goes, whether anyone
// is watching, and how to ask a question.
//
// The rule the whole CLI depends on: nothing prompts when stdin is not a
// terminal. A command that would need an answer fails with the flag that
// supplies it instead, which is what makes the surface scriptable.
package iostreams
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"golang.org/x/term"
)
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
stdinTTY bool
stdoutTTY bool
color bool
width int
}
// System builds the streams from the real process, reading the environment
// conventions everyone already expects: NO_COLOR off, FORCE_COLOR on.
func System() *IOStreams {
s := &IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}
s.stdinTTY = term.IsTerminal(int(os.Stdin.Fd()))
s.stdoutTTY = term.IsTerminal(int(os.Stdout.Fd()))
s.width = 80
if s.stdoutTTY {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 20 {
s.width = w
}
}
s.color = s.stdoutTTY && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
if os.Getenv("FORCE_COLOR") != "" {
s.color = true
}
return s
}
func (s *IOStreams) IsStdinTTY() bool { return s.stdinTTY }
func (s *IOStreams) IsStdoutTTY() bool { return s.stdoutTTY }
func (s *IOStreams) ColorEnabled() bool {
return s.color
}
func (s *IOStreams) TerminalWidth() int { return s.width }
// SetColor forces colour on or off (--no-color, or a test).
func (s *IOStreams) SetColor(on bool) { s.color = on }
func (s *IOStreams) Printf(format string, a ...any) { fmt.Fprintf(s.Out, format, a...) }
func (s *IOStreams) Println(a ...any) { fmt.Fprintln(s.Out, a...) }
func (s *IOStreams) Errorf(format string, a ...any) { fmt.Fprintf(s.ErrOut, format, a...) }
func (s *IOStreams) Errorln(a ...any) { fmt.Fprintln(s.ErrOut, a...) }
// Colour helpers. Each one is a no-op when colour is off, so call sites never
// branch and piped output never carries escape codes.
func (s *IOStreams) paint(code, text string) string {
if !s.color {
return text
}
return "\033[" + code + "m" + text + "\033[0m"
}
func (s *IOStreams) Bold(t string) string { return s.paint("1", t) }
func (s *IOStreams) Dim(t string) string { return s.paint("2", t) }
func (s *IOStreams) Red(t string) string { return s.paint("31", t) }
func (s *IOStreams) Green(t string) string { return s.paint("32", t) }
func (s *IOStreams) Yellow(t string) string { return s.paint("33", t) }
func (s *IOStreams) Blue(t string) string { return s.paint("34", t) }
func (s *IOStreams) Magenta(t string) string { return s.paint("35", t) }
func (s *IOStreams) Cyan(t string) string { return s.paint("36", t) }
func (s *IOStreams) Gray(t string) string { return s.paint("90", t) }
// Icons that degrade to ASCII, because a Windows console or a CI log should
// not render boxes.
func (s *IOStreams) Tick() string {
if s.color {
return s.Green("✓")
}
return "ok"
}
func (s *IOStreams) Cross() string {
if s.color {
return s.Red("✗")
}
return "x"
}
// ErrNoTTY is what every prompt returns when nobody is there to answer.
type ErrNoTTY struct{ Need string }
func (e *ErrNoTTY) Error() string {
return "this needs an answer and there is no terminal to ask on. " + e.Need
}
// Confirm asks a yes/no question. def is the answer a bare Enter gives.
func (s *IOStreams) Confirm(question string, def bool) (bool, error) {
if !s.stdinTTY {
return false, &ErrNoTTY{Need: "Pass --yes to answer yes without being asked."}
}
suffix := " [y/N] "
if def {
suffix = " [Y/n] "
}
reader := bufio.NewReader(s.In)
for {
fmt.Fprint(s.ErrOut, question+suffix)
line, err := reader.ReadString('\n')
if err != nil {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "":
return def, nil
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
}
fmt.Fprintln(s.ErrOut, "Please answer y or n.")
}
}
// Input asks for a line of text, offering def when the answer is empty.
func (s *IOStreams) Input(question, def string) (string, error) {
if !s.stdinTTY {
return "", &ErrNoTTY{Need: "Supply it with a flag instead."}
}
prompt := question
if def != "" {
prompt += " (" + def + ")"
}
fmt.Fprint(s.ErrOut, prompt+": ")
line, err := bufio.NewReader(s.In).ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
return def, nil
}
return line, nil
}
// Secret reads a value without echoing it. Used for pasting an API key.
func (s *IOStreams) Secret(question string) (string, error) {
if !s.stdinTTY {
// A piped secret is the documented CI path, so read it plainly.
// ReadString returns io.EOF alongside the final line when the input
// ends without a newline, which `printf '%s' "$KEY" |` always does;
// treating that as a failure would reject the exact form CI uses.
line, err := bufio.NewReader(s.In).ReadString('\n')
if errors.Is(err, io.EOF) {
err = nil
}
return strings.TrimSpace(line), err
}
fmt.Fprint(s.ErrOut, question+": ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(s.ErrOut)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
// Select asks the user to pick one of a list. On a terminal it draws an
// arrow-key menu; anywhere else it is a numbered prompt, which is also the
// fallback when raw mode is unavailable.
func (s *IOStreams) Select(question string, options []string) (int, error) {
if len(options) == 0 {
return 0, fmt.Errorf("nothing to choose from")
}
if len(options) == 1 {
return 0, nil
}
if !s.stdinTTY {
return 0, &ErrNoTTY{Need: "Supply the choice with a flag instead."}
}
if idx, err := s.selectInteractive(question, options); err == nil {
return idx, nil
}
return s.selectNumbered(question, options)
}
func (s *IOStreams) selectNumbered(question string, options []string) (int, error) {
fmt.Fprintln(s.ErrOut, question)
for i, o := range options {
fmt.Fprintf(s.ErrOut, " %d) %s\n", i+1, o)
}
reader := bufio.NewReader(s.In)
for {
fmt.Fprintf(s.ErrOut, "Choose 1-%d [1]: ", len(options))
line, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
line = strings.TrimSpace(line)
if line == "" {
return 0, nil
}
n, err := strconv.Atoi(line)
if err == nil && n >= 1 && n <= len(options) {
return n - 1, nil
}
fmt.Fprintln(s.ErrOut, "Not one of the options.")
}
}
// selectInteractive is the arrow-key menu. Every redraw rewinds exactly as
// many lines as it printed, so a wrapped line would draw over the screen
// above it: each option is clipped to the terminal width first.
func (s *IOStreams) selectInteractive(question string, options []string) (int, error) {
fd := int(os.Stdin.Fd())
state, err := term.MakeRaw(fd)
if err != nil {
return 0, err
}
defer func() { _ = term.Restore(fd, state) }()
cursor := 0
draw := func(first bool) {
if !first {
fmt.Fprintf(s.ErrOut, "\033[%dA", len(options))
}
for i, o := range options {
prefix := " "
line := o
if i == cursor {
prefix = s.Cyan("> ")
line = s.Bold(o)
}
fmt.Fprintf(s.ErrOut, "\r\033[K%s%s\r\n", prefix, s.clip(line, 2))
}
}
fmt.Fprintf(s.ErrOut, "%s %s\r\n", question, s.Dim("(arrows or j/k, enter to choose)"))
draw(true)
buf := make([]byte, 3)
for {
n, err := os.Stdin.Read(buf)
if err != nil {
return 0, err
}
switch {
case n == 1 && (buf[0] == '\r' || buf[0] == '\n'):
return cursor, nil
case n == 1 && (buf[0] == 3 || buf[0] == 27): // ctrl-c, esc
return 0, fmt.Errorf("cancelled")
case n == 1 && (buf[0] == 'j' || buf[0] == 'J'):
cursor = (cursor + 1) % len(options)
case n == 1 && (buf[0] == 'k' || buf[0] == 'K'):
cursor = (cursor - 1 + len(options)) % len(options)
case n >= 3 && buf[0] == 27 && buf[1] == '[':
switch buf[2] {
case 'B':
cursor = (cursor + 1) % len(options)
case 'A':
cursor = (cursor - 1 + len(options)) % len(options)
}
default:
// Digits pick directly, which is faster than arrowing down a list.
if n == 1 && buf[0] >= '1' && buf[0] <= '9' {
if idx := int(buf[0] - '1'); idx < len(options) {
cursor = idx
return cursor, nil
}
}
continue
}
draw(false)
}
}
// clip truncates to the terminal width, counting a prefix the caller already
// printed. Colour codes are invisible, so they are measured out first.
func (s *IOStreams) clip(text string, used int) string {
limit := s.width - used - 1
if limit < 10 {
limit = 10
}
if visibleLen(text) <= limit {
return text
}
// Truncating a coloured string mid-escape would leak codes; strip first.
plain := StripANSI(text)
if len(plain) <= limit {
return plain
}
return plain[:limit-1] + "…"
}
func visibleLen(s string) int { return len([]rune(StripANSI(s))) }
// StripANSI removes SGR escape sequences, so widths can be measured and
// redirected output never carries colour.
func StripANSI(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
j := i + 2
for j < len(s) && s[j] != 'm' {
j++
}
if j < len(s) {
i = j + 1
continue
}
}
b.WriteByte(s[i])
i++
}
return b.String()
}
+383
View File
@@ -0,0 +1,383 @@
// Package output renders an API response for whoever is reading it.
//
// A terminal gets a table, a pipe gets the JSON the API sent, and --template
// gets a Go template. The default flips on whether stdout is a terminal, so
// `warmbly campaign list` is readable and `warmbly campaign list > f.json` is
// parseable without anyone passing a flag.
package output
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"text/template"
"time"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
// Column is one table column: a header and where to read it from.
type Column struct {
Header string
// Path is dotted, so "organization.name" and "counts.sent" both work.
Path string
// Format names a renderer: time, date, bool, status, bytes, or empty for
// the value as it stands.
Format string
// Truncate caps the rendered width; 0 means no cap.
Truncate int
}
// Table describes how one endpoint's payload becomes rows.
type Table struct {
// Root is the dotted path to the array of rows. Empty means the payload is
// itself the array, or a single object rendered as one row.
Root string
Columns []Column
// Empty is what to say when there are no rows, phrased for the resource.
Empty string
}
// Printer holds the choice of renderer for one invocation.
type Printer struct {
IO *iostreams.IOStreams
JSON bool
Template string
// Fields narrows a table to named columns (--fields id,name).
Fields []string
}
// Print renders payload. table may be empty, in which case JSON is the only
// honest rendering and is used regardless of the terminal.
func (p *Printer) Print(payload []byte, table Table) error {
if p.Template != "" {
return p.renderTemplate(payload)
}
if p.JSON || len(table.Columns) == 0 || !p.IO.IsStdoutTTY() {
return p.renderJSON(payload)
}
return p.renderTable(payload, table)
}
func (p *Printer) renderJSON(payload []byte) error {
trimmed := bytes.TrimSpace(payload)
if len(trimmed) == 0 {
fmt.Fprintln(p.IO.Out, "{}")
return nil
}
var buf bytes.Buffer
if err := json.Indent(&buf, trimmed, "", " "); err != nil {
// Some endpoints stream a file. Pass it through untouched.
_, werr := p.IO.Out.Write(payload)
return werr
}
fmt.Fprintln(p.IO.Out, buf.String())
return nil
}
func (p *Printer) renderTemplate(payload []byte) error {
tmpl, err := template.New("out").Funcs(templateFuncs).Parse(p.Template)
if err != nil {
return fmt.Errorf("the --template is not a valid Go template: %w", err)
}
var data any
if err := json.Unmarshal(bytes.TrimSpace(payload), &data); err != nil {
return fmt.Errorf("the response is not JSON, so --template has nothing to walk: %w", err)
}
if err := tmpl.Execute(p.IO.Out, data); err != nil {
return err
}
fmt.Fprintln(p.IO.Out)
return nil
}
var templateFuncs = template.FuncMap{
"join": func(sep string, in []any) string {
parts := make([]string, 0, len(in))
for _, v := range in {
parts = append(parts, fmt.Sprint(v))
}
return strings.Join(parts, sep)
},
"pluck": func(field string, rows []any) []any {
out := make([]any, 0, len(rows))
for _, r := range rows {
if m, ok := r.(map[string]any); ok {
out = append(out, m[field])
}
}
return out
},
"timeago": func(v any) string { return relative(fmt.Sprint(v)) },
}
func (p *Printer) renderTable(payload []byte, table Table) error {
var doc any
if err := json.Unmarshal(bytes.TrimSpace(payload), &doc); err != nil {
return p.renderJSON(payload)
}
node := doc
if table.Root != "" {
node = dig(doc, table.Root)
}
var rows []any
switch v := node.(type) {
case []any:
rows = v
case map[string]any:
rows = []any{v}
case nil:
rows = nil
default:
return p.renderJSON(payload)
}
columns := table.Columns
if len(p.Fields) > 0 {
columns = filterColumns(columns, p.Fields)
if len(columns) == 0 {
return fmt.Errorf("none of the requested fields exist here. Available: %s", strings.Join(headerNames(table.Columns), ", "))
}
}
if len(rows) == 0 {
empty := table.Empty
if empty == "" {
empty = "Nothing here yet."
}
fmt.Fprintln(p.IO.Out, p.IO.Gray(empty))
return nil
}
cells := make([][]string, 0, len(rows)+1)
header := make([]string, len(columns))
for i, c := range columns {
header[i] = strings.ToUpper(c.Header)
}
cells = append(cells, header)
for _, r := range rows {
row := make([]string, len(columns))
for i, c := range columns {
row[i] = render(dig(r, c.Path), c)
}
cells = append(cells, row)
}
p.writeTable(cells)
return nil
}
// writeTable pads to the widest cell per column, then drops trailing columns
// that no longer fit rather than wrapping: a wrapped table is unreadable and
// the JSON is one flag away.
func (p *Printer) writeTable(cells [][]string) {
if len(cells) == 0 {
return
}
cols := len(cells[0])
widths := make([]int, cols)
for _, row := range cells {
for i, cell := range row {
if n := len([]rune(iostreams.StripANSI(cell))); n > widths[i] {
widths[i] = n
}
}
}
limit := p.IO.TerminalWidth()
keep := cols
used := 0
for i := 0; i < cols; i++ {
next := used + widths[i] + 2
if i > 0 && next > limit {
keep = i
break
}
used = next
}
if keep < 1 {
keep = 1
}
for r, row := range cells {
var line strings.Builder
for i := 0; i < keep; i++ {
cell := row[i]
if r == 0 {
cell = p.IO.Gray(cell)
}
line.WriteString(cell)
if i < keep-1 {
pad := widths[i] - len([]rune(iostreams.StripANSI(row[i]))) + 2
line.WriteString(strings.Repeat(" ", pad))
}
}
fmt.Fprintln(p.IO.Out, strings.TrimRight(line.String(), " "))
}
}
func filterColumns(cols []Column, want []string) []Column {
keep := make(map[string]bool, len(want))
for _, w := range want {
keep[strings.ToLower(strings.TrimSpace(w))] = true
}
out := make([]Column, 0, len(cols))
for _, c := range cols {
if keep[strings.ToLower(c.Header)] || keep[strings.ToLower(c.Path)] {
out = append(out, c)
}
}
return out
}
func headerNames(cols []Column) []string {
out := make([]string, 0, len(cols))
for _, c := range cols {
out = append(out, strings.ToLower(c.Header))
}
return out
}
// dig walks a dotted path through decoded JSON. A numeric segment indexes an
// array, so "data.0.name" works the way anyone would expect it to.
func dig(node any, path string) any {
if path == "" {
return node
}
cur := node
for _, seg := range strings.Split(path, ".") {
if cur == nil {
return nil
}
switch v := cur.(type) {
case map[string]any:
cur = v[seg]
case []any:
idx, err := strconv.Atoi(seg)
if err != nil || idx < 0 || idx >= len(v) {
return nil
}
cur = v[idx]
default:
return nil
}
}
return cur
}
func render(v any, c Column) string {
s := stringify(v)
switch c.Format {
case "time":
s = relative(s)
case "date":
s = shortDate(s)
case "bool":
if b, ok := v.(bool); ok {
if b {
return "yes"
}
return "no"
}
case "int":
if f, ok := v.(float64); ok {
return strconv.FormatInt(int64(f), 10)
}
}
if c.Truncate > 0 && len([]rune(s)) > c.Truncate {
s = string([]rune(s)[:c.Truncate-1]) + "…"
}
return s
}
func stringify(v any) string {
switch t := v.(type) {
case nil:
return "-"
case string:
if t == "" {
return "-"
}
return t
case bool:
if t {
return "yes"
}
return "no"
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', 2, 64)
case []any:
parts := make([]string, 0, len(t))
for _, item := range t {
parts = append(parts, stringify(item))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, ",")
case map[string]any:
// A nested object in a table cell is noise; name it if it has a name.
for _, key := range []string{"name", "email", "title", "id"} {
if s, ok := t[key].(string); ok && s != "" {
return s
}
}
return "{…}"
default:
return fmt.Sprint(t)
}
}
// relative turns a timestamp into "3h ago", which is what a person reading a
// list actually wants to know.
func relative(raw string) string {
if raw == "" || raw == "-" {
return "-"
}
ts, err := time.Parse(time.RFC3339, raw)
if err != nil {
return raw
}
d := time.Since(ts)
future := ""
if d < 0 {
d = -d
future = "in "
}
suffix := " ago"
if future != "" {
suffix = ""
}
switch {
case d < time.Minute:
if future != "" {
return "in a moment"
}
return "just now"
case d < time.Hour:
return fmt.Sprintf("%s%dm%s", future, int(d.Minutes()), suffix)
case d < 24*time.Hour:
return fmt.Sprintf("%s%dh%s", future, int(d.Hours()), suffix)
case d < 30*24*time.Hour:
return fmt.Sprintf("%s%dd%s", future, int(d.Hours()/24), suffix)
default:
return ts.Local().Format("2 Jan 2006")
}
}
func shortDate(raw string) string {
if raw == "" || raw == "-" {
return "-"
}
ts, err := time.Parse(time.RFC3339, raw)
if err != nil {
return raw
}
return ts.Local().Format("2006-01-02 15:04")
}
+118
View File
@@ -0,0 +1,118 @@
package output
import (
"bytes"
"strings"
"testing"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
func testPrinter() (*Printer, *bytes.Buffer) {
buf := &bytes.Buffer{}
io := iostreams.System()
io.Out = buf
io.SetColor(false)
return &Printer{IO: io}, buf
}
func TestDigWalksObjectsAndArrays(t *testing.T) {
doc := map[string]any{
"data": []any{
map[string]any{"name": "first", "counts": map[string]any{"sent": 4.0}},
},
}
if got := dig(doc, "data.0.name"); got != "first" {
t.Errorf("dig name = %v", got)
}
if got := dig(doc, "data.0.counts.sent"); got != 4.0 {
t.Errorf("dig nested = %v", got)
}
if got := dig(doc, "data.9.name"); got != nil {
t.Errorf("out of range should be nil, got %v", got)
}
if got := dig(doc, "missing.key"); got != nil {
t.Errorf("missing path should be nil, got %v", got)
}
}
func TestTableRendersRowsAndHeaders(t *testing.T) {
p, buf := testPrinter()
// A table is only rendered for a terminal; force it directly instead.
err := p.renderTable([]byte(`{"data":[{"id":"1","name":"Alpha","status":"active"},{"id":"2","name":"Beta","status":"draft"}]}`),
Table{Root: "data", Columns: []Column{{Header: "ID", Path: "id"}, {Header: "NAME", Path: "name"}, {Header: "STATUS", Path: "status"}}})
if err != nil {
t.Fatalf("render: %v", err)
}
out := buf.String()
for _, want := range []string{"ID", "NAME", "STATUS", "Alpha", "Beta", "draft"} {
if !strings.Contains(out, want) {
t.Errorf("table is missing %q:\n%s", want, out)
}
}
if lines := strings.Count(strings.TrimSpace(out), "\n"); lines != 2 {
t.Errorf("want a header and two rows, got:\n%s", out)
}
}
func TestEmptyListSaysSomethingUseful(t *testing.T) {
p, buf := testPrinter()
if err := p.renderTable([]byte(`{"data":[]}`), Table{Root: "data", Columns: []Column{{Header: "ID", Path: "id"}}, Empty: "No campaigns yet."}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(buf.String(), "No campaigns yet.") {
t.Errorf("an empty list should explain itself, got %q", buf.String())
}
}
func TestFieldsNarrowTheTable(t *testing.T) {
p, buf := testPrinter()
p.Fields = []string{"name"}
if err := p.renderTable([]byte(`{"data":[{"id":"1","name":"Alpha"}]}`),
Table{Root: "data", Columns: []Column{{Header: "ID", Path: "id"}, {Header: "NAME", Path: "name"}}}); err != nil {
t.Fatalf("render: %v", err)
}
out := buf.String()
if strings.Contains(out, "ID") {
t.Errorf("--fields name should drop the ID column:\n%s", out)
}
if !strings.Contains(out, "Alpha") {
t.Errorf("--fields name dropped the row:\n%s", out)
}
}
func TestJSONIsPassedThroughWhenNotAnObject(t *testing.T) {
p, buf := testPrinter()
if err := p.renderJSON([]byte("not json at all")); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(buf.String(), "not json at all") {
t.Errorf("a non-JSON body must pass through untouched, got %q", buf.String())
}
}
func TestTemplateRendering(t *testing.T) {
p, buf := testPrinter()
p.Template = "{{range .data}}{{.name}} {{end}}"
if err := p.Print([]byte(`{"data":[{"name":"a"},{"name":"b"}]}`), Table{}); err != nil {
t.Fatalf("template: %v", err)
}
if strings.TrimSpace(buf.String()) != "a b" {
t.Errorf("template output = %q", buf.String())
}
}
func TestStringifyKeepsCellsReadable(t *testing.T) {
if got := stringify(nil); got != "-" {
t.Errorf("nil = %q, want -", got)
}
if got := stringify([]any{"a@x.com", "b@x.com"}); got != "a@x.com,b@x.com" {
t.Errorf("list = %q", got)
}
if got := stringify(map[string]any{"id": "x", "name": "Jane"}); got != "Jane" {
t.Errorf("object cell = %q, want the name", got)
}
if got := stringify(4.0); got != "4" {
t.Errorf("whole float = %q, want 4", got)
}
}
+305
View File
@@ -0,0 +1,305 @@
// Package update keeps an installed CLI current.
//
// Two jobs: telling someone a newer release exists without getting in their
// way, and replacing the binary when they ask for it.
//
// The version lookup deliberately does not use the GitHub API. The
// unauthenticated API is rate limited per IP, which on a shared CI runner or
// behind a corporate NAT means the check fails for everyone at once; the
// releases/latest redirect is a plain HTTP redirect with no such limit.
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
repo = "warmbly/warmbly"
// LatestURL redirects to the newest release's tag page.
LatestURL = "https://github.com/" + repo + "/releases/latest"
// DownloadBase is where the release assets live. Names carry no version,
// so "latest" resolves without knowing the tag first.
DownloadBase = "https://github.com/" + repo + "/releases/latest/download"
)
// CheckInterval is how often the background nudge looks for a new release.
// Once a day: often enough to matter, rare enough that nobody notices it.
const CheckInterval = 24 * time.Hour
// LatestVersion resolves the newest published release tag by following the
// latest-release redirect and reading the tag out of the final URL.
func LatestVersion(ctx context.Context, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := &http.Client{
// Stop at the redirect: the tag is in the Location header, and
// following it would download an HTML page for nothing.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodHead, LatestURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
location := resp.Header.Get("Location")
if location == "" {
return "", errors.New("no release redirect")
}
tag := location[strings.LastIndex(location, "/")+1:]
if !strings.HasPrefix(tag, "v") {
return "", fmt.Errorf("unexpected release tag %q", tag)
}
return tag, nil
}
// IsNewer reports whether candidate is a later release than current. Both are
// vX.Y.Z. A current version that is not a clean release tag (a dev build, a
// git describe string) returns false: someone running their own build does not
// want to be told to download ours.
func IsNewer(current, candidate string) bool {
cur, ok := parseVersion(current)
if !ok {
return false
}
next, ok := parseVersion(candidate)
if !ok {
return false
}
for i := 0; i < 3; i++ {
if next[i] != cur[i] {
return next[i] > cur[i]
}
}
return false
}
func parseVersion(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
// A git describe string (1.2.3-4-gabc1234) or a prerelease is not a plain
// release, so it never compares.
if v == "" || strings.ContainsAny(v, "-+ ") {
return out, false
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return out, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
// Method is how this binary got here, which decides how it should be replaced.
type Method int
const (
// MethodBinary is a plain binary we can overwrite ourselves.
MethodBinary Method = iota
MethodHomebrew
MethodScoop
MethodGoInstall
MethodPackage
)
// UpgradeCommand is what to tell the user to run when we must not replace the
// binary ourselves. Empty when a self-replace is the right answer.
func (m Method) UpgradeCommand() string {
switch m {
case MethodHomebrew:
return "brew upgrade warmbly"
case MethodScoop:
return "scoop update warmbly"
case MethodGoInstall:
return "go install github.com/" + repo + "/cmd/cli@latest"
case MethodPackage:
return "your package manager"
default:
return ""
}
}
// DetectMethod works out how this binary was installed from where it sits.
// Fighting a package manager by overwriting the file it owns produces a
// version that reverts on the next upgrade, so this is what stops that.
func DetectMethod(executable string) Method {
path, err := filepath.EvalSymlinks(executable)
if err != nil {
path = executable
}
// Backslashes are normalised explicitly rather than with filepath.ToSlash,
// which is a no-op off Windows: the detection then behaves the same
// wherever it runs, including in a test.
lower := strings.ToLower(strings.ReplaceAll(path, `\`, "/"))
switch {
case strings.Contains(lower, "/cellar/"), strings.Contains(lower, "/homebrew/"),
strings.Contains(lower, "/linuxbrew/"):
return MethodHomebrew
case strings.Contains(lower, "/scoop/"):
return MethodScoop
case strings.Contains(lower, "/go/bin/"), strings.HasSuffix(lower, "/gopath/bin/warmbly"):
return MethodGoInstall
case strings.HasPrefix(lower, "/usr/bin/"), strings.HasPrefix(lower, "/opt/"),
strings.HasPrefix(lower, "/snap/"), strings.HasPrefix(lower, "/nix/"):
return MethodPackage
default:
return MethodBinary
}
}
// AssetName is the archive published for the running platform.
func AssetName() string {
if runtime.GOOS == "windows" {
return fmt.Sprintf("warmbly_%s_%s.zip", runtime.GOOS, runtime.GOARCH)
}
return fmt.Sprintf("warmbly_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH)
}
// Replace downloads the newest build for this platform, verifies it against
// the published checksums, and swaps it in for the running binary.
//
// The swap is a rename, which is atomic: an interrupted upgrade leaves either
// the old binary or the new one, never half of either.
func Replace(ctx context.Context, executable string, progress func(string)) error {
if runtime.GOOS == "windows" {
return errors.New("self-upgrade is not supported on Windows because a running .exe cannot be replaced.\nRun the installer again instead:\n irm https://warmbly.com/cli.ps1 | iex")
}
asset := AssetName()
progress("downloading " + asset)
archive, err := download(ctx, DownloadBase+"/"+asset)
if err != nil {
return err
}
progress("verifying checksum")
sums, err := download(ctx, DownloadBase+"/checksums.txt")
if err != nil {
return fmt.Errorf("could not fetch checksums.txt, so the download was not verified: %w", err)
}
want := checksumFor(string(sums), asset)
if want == "" {
return fmt.Errorf("checksums.txt has no entry for %s", asset)
}
sum := sha256.Sum256(archive)
if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("checksum mismatch for %s.\n expected %s\n got %s\nNothing was changed", asset, want, got)
}
progress("unpacking")
binary, err := extractBinary(archive)
if err != nil {
return err
}
target, err := filepath.EvalSymlinks(executable)
if err != nil {
target = executable
}
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".warmbly-upgrade-*")
if err != nil {
return fmt.Errorf("cannot write to %s: %w\nIf it is system-owned, re-run the installer instead:\n curl -fsSL https://warmbly.com/cli.sh | sh", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(binary); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o755); err != nil {
return err
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("could not replace %s: %w", target, err)
}
return nil
}
func download(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("could not download %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("could not download %s: HTTP %d", url, resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 200<<20))
}
func checksumFor(sums, asset string) string {
for _, line := range strings.Split(sums, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == asset {
return fields[0]
}
}
return ""
}
// extractBinary pulls just the warmbly executable out of the release archive.
func extractBinary(archive []byte) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, fmt.Errorf("the archive is not readable: %w", err)
}
defer gz.Close()
reader := tar.NewReader(gz)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if header.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(header.Name) != "warmbly" {
continue
}
return io.ReadAll(io.LimitReader(reader, 200<<20))
}
return nil, errors.New("the archive did not contain a warmbly binary")
}
+97
View File
@@ -0,0 +1,97 @@
package update
import (
"runtime"
"strings"
"testing"
)
func TestIsNewer(t *testing.T) {
cases := []struct {
current, candidate string
want bool
}{
{"v1.2.3", "v1.2.4", true},
{"v1.2.3", "v1.3.0", true},
{"v1.2.3", "v2.0.0", true},
{"v1.2.3", "v1.2.3", false},
{"v1.2.3", "v1.2.2", false},
{"v1.10.0", "v1.9.0", false},
{"v1.9.0", "v1.10.0", true},
// Someone running their own build is not behind ours, and must not be
// told to download something.
{"dev", "v1.2.3", false},
{"v1.2.3-4-gabc1234", "v1.2.4", false},
{"", "v1.2.3", false},
// A malformed tag on the far side is ignored rather than trusted.
{"v1.2.3", "not-a-version", false},
{"v1.2.3", "v1.2", false},
}
for _, c := range cases {
if got := IsNewer(c.current, c.candidate); got != c.want {
t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.candidate, got, c.want)
}
}
}
func TestDetectMethod(t *testing.T) {
cases := map[string]Method{
"/home/jane/.local/bin/warmbly": MethodBinary,
"/opt/homebrew/bin/warmbly": MethodHomebrew,
"/usr/local/Cellar/warmbly/1.0/bin/warmbly": MethodHomebrew,
"/home/linuxbrew/.linuxbrew/bin/warmbly": MethodHomebrew,
"C:\\Users\\jane\\scoop\\shims\\warmbly.exe": MethodScoop,
"/home/jane/go/bin/warmbly": MethodGoInstall,
"/usr/bin/warmbly": MethodPackage,
"/nix/store/abc-warmbly/bin/warmbly": MethodPackage,
}
for path, want := range cases {
if got := DetectMethod(path); got != want {
t.Errorf("DetectMethod(%q) = %v, want %v", path, got, want)
}
}
}
// Replacing a file a package manager owns produces a version that silently
// reverts on its next upgrade, so each of these has to name a command.
func TestPackageMethodsNameACommand(t *testing.T) {
for _, m := range []Method{MethodHomebrew, MethodScoop, MethodGoInstall, MethodPackage} {
if m.UpgradeCommand() == "" {
t.Errorf("method %v self-replaces; it must tell the user what to run instead", m)
}
}
if MethodBinary.UpgradeCommand() != "" {
t.Error("a plain binary should be replaced in place, not delegated")
}
}
func TestAssetNameMatchesWhatWePublish(t *testing.T) {
name := AssetName()
if !strings.HasPrefix(name, "warmbly_"+runtime.GOOS+"_"+runtime.GOARCH) {
t.Errorf("asset name %q does not name this platform", name)
}
if runtime.GOOS == "windows" {
if !strings.HasSuffix(name, ".zip") {
t.Errorf("windows asset %q should be a zip", name)
}
} else if !strings.HasSuffix(name, ".tar.gz") {
t.Errorf("unix asset %q should be a tar.gz", name)
}
}
func TestChecksumFor(t *testing.T) {
sums := `abc123 warmbly_linux_amd64.tar.gz
def456 warmbly_darwin_arm64.tar.gz
`
if got := checksumFor(sums, "warmbly_linux_amd64.tar.gz"); got != "abc123" {
t.Errorf("got %q", got)
}
if got := checksumFor(sums, "warmbly_windows_amd64.zip"); got != "" {
t.Errorf("an absent asset must report no checksum, got %q", got)
}
// The BSD-style "*name" form has to resolve too, or verification silently
// degrades to a warning on machines whose sha tool writes it.
if got := checksumFor("abc123 *warmbly_linux_amd64.tar.gz", "warmbly_linux_amd64.tar.gz"); got != "abc123" {
t.Errorf("star-prefixed name not matched, got %q", got)
}
}
+5
View File
@@ -296,6 +296,11 @@ const (
WarmupPoolFallbackMinAgeDays = 3 // other-tier mailboxes must be this old before they fill in
DailyThrottleNewOrgs = 3 // new workspaces per owner per day
// CLI sign-in handshake (`warmbly auth login`). Shorter-lived than the pool
// link handshake because a person is watching the terminal while it runs.
CLIAuthCodeTTLMinutes = 10
CLIAuthPollIntervalSeconds = 3
// DailyThrottleNewScheduledSends caps how many NEW scheduled-send
// schedules a single user can create in a rolling 24h window. The
// real defense against burst abuse — someone writing a loop that
+24
View File
@@ -23,6 +23,30 @@ func AppBaseURL() string {
return "https://app.warmbly.com"
}
// WebsocketURL is the realtime gateway clients connect to. It is deployment
// configuration rather than a secret, which is why GET /v1/auth/config serves
// it: a CLI or a developer client cannot otherwise find the socket on a
// self-hosted instance, where the host layout is whatever the operator chose.
func WebsocketURL() string {
v := strings.TrimRight(strings.TrimSpace(os.Getenv("WEBSOCKET_URL")), "/")
if v == "" {
return ""
}
// The variable is written three ways in the wild: a bare host, the Phoenix
// socket mount (".../socket"), and the full transport endpoint. Clients
// dial what this returns, so all three normalise to the last one. Matching
// on a "/socket" substring instead of the suffix left ".../socket"
// untouched, which is not a websocket endpoint.
switch {
case strings.HasSuffix(v, "/socket/websocket"):
case strings.HasSuffix(v, "/socket"):
v += "/websocket"
default:
v += "/socket/websocket"
}
return v
}
func GetPasswordResetURL(sessionToken string) string {
return AppBaseURL() + "/auth/reset-password/confirm?session=" + url.QueryEscape(sessionToken)
}
+23
View File
@@ -0,0 +1,23 @@
package config
import "testing"
// Clients dial whatever GET /v1/auth/config advertises, so every form an
// operator plausibly writes has to normalise to the Phoenix transport path.
func TestWebsocketURLNormalisation(t *testing.T) {
cases := map[string]string{
"wss://ws.example.com": "wss://ws.example.com/socket/websocket",
"wss://ws.example.com/": "wss://ws.example.com/socket/websocket",
"wss://ws.example.com/socket": "wss://ws.example.com/socket/websocket",
"wss://ws.example.com/socket/": "wss://ws.example.com/socket/websocket",
"wss://ws.example.com/socket/websocket": "wss://ws.example.com/socket/websocket",
"ws://localhost:4000/socket/websocket": "ws://localhost:4000/socket/websocket",
"": "",
}
for in, want := range cases {
t.Setenv("WEBSOCKET_URL", in)
if got := WebsocketURL(); got != want {
t.Errorf("WebsocketURL(%q) = %q, want %q", in, got, want)
}
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cli_auth_codes;
@@ -0,0 +1,28 @@
-- CLI sign-in: the `warmbly` CLI has no credential of its own, so it opens a
-- device-code handshake, a signed-in member approves it in the browser, and the
-- approval mints an ordinary API key. The key is the credential; this table
-- only carries the handshake and is empty within minutes.
--
-- Same shape as pool_link_codes, one row per `warmbly auth login`.
CREATE TABLE IF NOT EXISTS cli_auth_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_code_hash text NOT NULL UNIQUE,
user_code text NOT NULL UNIQUE,
-- What the CLI asked for, shown on the approval screen.
client_name text NOT NULL DEFAULT '',
hostname text NOT NULL DEFAULT '',
cli_version text NOT NULL DEFAULT '',
scopes bigint NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'claimed', 'denied')),
organization_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
approved_by uuid REFERENCES users (id) ON DELETE SET NULL,
api_key_id uuid REFERENCES api_keys (id) ON DELETE SET NULL,
-- The minted secret, held only between approval and the CLI's next poll.
api_key_secret text,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cli_auth_codes_expires ON cli_auth_codes (expires_at);
+91
View File
@@ -0,0 +1,91 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CLI sign-in: `warmbly auth login` opens a device-code handshake, a member
// approves it in the browser, and the approval mints an ordinary API key.
// CLIAuthCodeStatus is the lifecycle of one handshake.
type CLIAuthCodeStatus string
const (
CLIAuthCodePending CLIAuthCodeStatus = "pending"
CLIAuthCodeApproved CLIAuthCodeStatus = "approved"
// Claimed: the CLI has fetched its key, the code is spent.
CLIAuthCodeClaimed CLIAuthCodeStatus = "claimed"
CLIAuthCodeDenied CLIAuthCodeStatus = "denied"
)
// CLIAuthCode is what the approving member is shown before deciding.
type CLIAuthCode struct {
ID uuid.UUID `json:"id"`
UserCode string `json:"user_code"`
ClientName string `json:"client_name"`
Hostname string `json:"hostname"`
CLIVersion string `json:"cli_version"`
Scopes uint64 `json:"scopes"`
ScopeNames []string `json:"scope_names"`
Status CLIAuthCodeStatus `json:"status"`
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
// APIKeyID is set only on the approval response: the key that was minted.
APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// CLIAuthStartRequest is what the CLI sends to open a handshake. Every field is
// display-only except Scopes, which bounds the key the approval mints.
type CLIAuthStartRequest struct {
ClientName string `json:"client_name"`
Hostname string `json:"hostname"`
CLIVersion string `json:"cli_version"`
Scopes uint64 `json:"scopes"`
}
// CLIAuthStartResponse is RFC 8628 shaped, so a generic device-flow client works.
type CLIAuthStartResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_uri"`
// VerificationURLComplete carries the code, so the browser needs no typing.
VerificationURLComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// CLIAuthPollResponse answers one poll. Status is the only field always set;
// the key fields arrive exactly once, on the poll that claims an approved code.
type CLIAuthPollResponse struct {
Status CLIAuthCodeStatus `json:"status"`
Token string `json:"token,omitempty"`
APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
Scopes uint64 `json:"scopes,omitempty"`
ScopeNames []string `json:"scope_names,omitempty"`
UserID *uuid.UUID `json:"user_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OrganizationName string `json:"organization_name,omitempty"`
}
// CLIAuthApproveRequest names the workspace the key is minted in.
type CLIAuthApproveRequest struct {
OrganizationID string `json:"organization_id"`
}
// APIScopeNames turns a permission bitmask into the scope names the CLI and the
// approval screen show, in the canonical order of AllAPIPermissions.
func APIScopeNames(mask uint64) []string {
names := make([]string, 0, len(AllAPIPermissions))
for _, p := range AllAPIPermissions {
if mask&p.Value == p.Value {
names = append(names, p.Name)
}
}
return names
}
+159
View File
@@ -0,0 +1,159 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
// CLIAuthRepository stores the `warmbly auth login` handshake. Rows live for
// minutes: the credential the flow produces is an ordinary API key.
type CLIAuthRepository interface {
CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error)
GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error)
// ApproveCode stores the minted secret for the next poll; false when the
// code is no longer pending, which is what makes approval single-use.
ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error)
DenyCode(ctx context.Context, userCode string) (bool, error)
// ClaimCode hands the secret out exactly once, clearing it in the same statement.
ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error)
DeleteExpiredCodes(ctx context.Context) error
}
type cliAuthRepository struct {
db *pgxpool.Pool
}
func NewCLIAuthRepository(db *pgxpool.Pool) CLIAuthRepository {
return &cliAuthRepository{db: db}
}
const cliAuthCodeColumns = `id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at`
func scanCLIAuthCode(row pgx.Row) (*models.CLIAuthCode, error) {
var c models.CLIAuthCode
var scopes int64
if err := row.Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
c.Scopes = uint64(scopes)
c.ScopeNames = models.APIScopeNames(c.Scopes)
return &c, nil
}
func (r *cliAuthRepository) CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error) {
query := `
INSERT INTO cli_auth_codes (device_code_hash, user_code, client_name, hostname, cli_version, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING ` + cliAuthCodeColumns
c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, deviceCodeHash, userCode, req.ClientName, req.Hostname, req.CLIVersion, int64(req.Scopes), expiresAt))
if err != nil {
db.CaptureError(err, query, nil, "queryrow")
return nil, err
}
return c, nil
}
func (r *cliAuthRepository) GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error) {
query := `SELECT ` + cliAuthCodeColumns + ` FROM cli_auth_codes WHERE user_code = $1 AND expires_at > NOW()`
c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, userCode))
if err != nil {
db.CaptureError(err, query, []any{userCode}, "queryrow")
return nil, err
}
return c, nil
}
func (r *cliAuthRepository) ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error) {
query := `
UPDATE cli_auth_codes
SET status = 'approved', organization_id = $2, approved_by = $3, api_key_id = $4, api_key_secret = $5
WHERE user_code = $1 AND status = 'pending' AND expires_at > NOW()
`
tag, err := r.db.Exec(ctx, query, userCode, orgID, approvedBy, apiKeyID, secret)
if err != nil {
db.CaptureError(err, query, nil, "exec")
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *cliAuthRepository) DenyCode(ctx context.Context, userCode string) (bool, error) {
query := `UPDATE cli_auth_codes SET status = 'denied' WHERE user_code = $1 AND status = 'pending'`
tag, err := r.db.Exec(ctx, query, userCode)
if err != nil {
db.CaptureError(err, query, []any{userCode}, "exec")
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *cliAuthRepository) ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error) {
// The secret comes from the locked pre-update row; RETURNING would only
// see the cleared value.
query := `
WITH picked AS (
SELECT id, api_key_secret
FROM cli_auth_codes
WHERE device_code_hash = $1 AND status = 'approved' AND expires_at > NOW()
FOR UPDATE
), claimed AS (
UPDATE cli_auth_codes p
SET status = 'claimed', api_key_secret = NULL
FROM picked
WHERE p.id = picked.id
RETURNING p.id, p.user_code, p.client_name, p.hostname, p.cli_version, p.scopes, p.status, p.organization_id, p.expires_at, p.created_at, picked.api_key_secret AS secret
)
SELECT id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at, COALESCE(secret, '') FROM claimed
UNION ALL
SELECT ` + cliAuthCodeColumns + `, '' FROM cli_auth_codes
WHERE device_code_hash = $1 AND expires_at > NOW() AND NOT EXISTS (SELECT 1 FROM claimed)
LIMIT 1
`
var c models.CLIAuthCode
var scopes int64
var secret string
err := r.db.QueryRow(ctx, query, deviceCodeHash).Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt, &secret)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
db.CaptureError(err, query, nil, "queryrow")
return nil, "", err
}
c.Scopes = uint64(scopes)
c.ScopeNames = models.APIScopeNames(c.Scopes)
// The claimed row reports its new status; the caller wants "approved".
if secret != "" {
c.Status = models.CLIAuthCodeApproved
}
return &c, secret, nil
}
// DeleteExpiredCodes both destroys the plaintext secret on any expired row and
// removes rows old enough to be of no interest.
//
// The two are separate on purpose. An approved code the CLI never came back
// for would otherwise keep a usable key in plaintext for as long as the row
// survived, which is exactly what the "held only between approval and the next
// poll" intent rules out. Blanking it the moment the code expires bounds that
// to the code's own ten minutes. The key itself stays: it was legitimately
// created and is listed under Settings > API keys, but nobody holds its secret.
func (r *cliAuthRepository) DeleteExpiredCodes(ctx context.Context) error {
query := `UPDATE cli_auth_codes SET api_key_secret = NULL WHERE api_key_secret IS NOT NULL AND expires_at < NOW()`
if _, err := r.db.Exec(ctx, query); err != nil {
db.CaptureError(err, query, nil, "exec")
return err
}
_, err := r.db.Exec(ctx, `DELETE FROM cli_auth_codes WHERE expires_at < NOW() - INTERVAL '1 day'`)
return err
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
#
# Builds the `warmbly` CLI for every platform we publish, packages each one,
# and writes the manifests the package managers read.
#
# Run by the release workflow and by `make cli-dist`, so a release artifact can
# be reproduced locally byte for byte given the same VERSION and COMMIT.
#
# ./scripts/build-cli.sh dist
#
# Assets are named without the version on purpose: the install script resolves
# https://github.com/warmbly/warmbly/releases/latest/download/warmbly_<os>_<arch>.tar.gz
# with no GitHub API call, and the unauthenticated API's rate limit is exactly
# what breaks a curl installer on a shared CI runner.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=${1:-dist}
REPO=warmbly/warmbly
MODULE=github.com/warmbly/warmbly
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}
COMMIT=${COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "")}
BUILT_AT=${BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}
# Every platform the install script and the package managers know how to ask
# for. Keep this list and the one in site/public/cli.sh in step; the installer
# check verifies they agree.
PLATFORMS="darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"
LDFLAGS="-s -w
-X ${MODULE}/internal/version.Version=${VERSION}
-X ${MODULE}/internal/version.Commit=${COMMIT}
-X ${MODULE}/internal/version.BuiltAt=${BUILT_AT}"
# macOS ships shasum and not GNU sha256sum, and this script is meant to be
# reproducible on a maintainer's laptop as well as on the release runner.
sha256_all() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
rm -rf "$OUT"
mkdir -p "$OUT"
# Completions ship inside every archive so the install script can drop them in
# without running the binary it just downloaded, which it cannot do for a
# cross-platform install anyway.
stage_completions() {
local host_bin=$1 dest=$2
mkdir -p "$dest"
for shell in bash zsh fish powershell; do
"$host_bin" completion "$shell" > "$dest/warmbly.$shell" 2>/dev/null || true
done
}
echo "building warmbly ${VERSION}"
host_bin="$OUT/.host/warmbly"
mkdir -p "$OUT/.host"
# shellcheck disable=SC2086
go build -ldflags="$LDFLAGS" -o "$host_bin" ./cmd/cli
completions="$OUT/.completions"
stage_completions "$host_bin" "$completions"
for target in $PLATFORMS; do
os=${target%/*}
arch=${target#*/}
ext=""
if [ "$os" = "windows" ]; then ext=".exe"; fi
stage="$OUT/.stage/warmbly_${os}_${arch}"
mkdir -p "$stage"
echo " $os/$arch"
# shellcheck disable=SC2086
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -ldflags="$LDFLAGS" -o "$stage/warmbly${ext}" ./cmd/cli
cp LICENSE README.md "$stage/"
cp -r "$completions" "$stage/completions"
if [ "$os" = "windows" ]; then
(cd "$stage" && zip -qr "../../warmbly_${os}_${arch}.zip" .)
else
tar -czf "$OUT/warmbly_${os}_${arch}.tar.gz" -C "$stage" .
fi
done
rm -rf "$OUT/.stage" "$OUT/.host" "$OUT/.completions"
(cd "$OUT" && sha256_all warmbly_* > checksums.txt)
echo
cat "$OUT/checksums.txt"
# ─────────────────────────────────────────────────────────────────────────
# Package manager manifests
#
# Written here rather than by hand so the checksums in them can never drift
# from the archives they describe, which is the failure mode that makes a tap
# install fail for everyone at once.
# ─────────────────────────────────────────────────────────────────────────
sum_for() { awk -v f="$1" '$2 == f { print $1 }' "$OUT/checksums.txt"; }
BASE="https://github.com/${REPO}/releases/download/${VERSION}"
cat > "$OUT/warmbly.rb" <<EOF
# Homebrew formula for the Warmbly CLI. Generated by scripts/build-cli.sh;
# the release workflow pushes it to the warmbly/homebrew-tap repository.
class Warmbly < Formula
desc "Warmbly from the command line: campaigns, contacts, mailboxes, inbox"
homepage "https://warmbly.com"
version "${VERSION#v}"
license "Apache-2.0"
on_macos do
on_intel do
url "${BASE}/warmbly_darwin_amd64.tar.gz"
sha256 "$(sum_for warmbly_darwin_amd64.tar.gz)"
end
on_arm do
url "${BASE}/warmbly_darwin_arm64.tar.gz"
sha256 "$(sum_for warmbly_darwin_arm64.tar.gz)"
end
end
on_linux do
on_intel do
url "${BASE}/warmbly_linux_amd64.tar.gz"
sha256 "$(sum_for warmbly_linux_amd64.tar.gz)"
end
on_arm do
url "${BASE}/warmbly_linux_arm64.tar.gz"
sha256 "$(sum_for warmbly_linux_arm64.tar.gz)"
end
end
def install
bin.install "warmbly"
bash_completion.install "completions/warmbly.bash" => "warmbly"
zsh_completion.install "completions/warmbly.zsh" => "_warmbly"
fish_completion.install "completions/warmbly.fish" => "warmbly.fish"
end
test do
assert_match "warmbly", shell_output("#{bin}/warmbly version")
end
end
EOF
cat > "$OUT/warmbly.json" <<EOF
{
"version": "${VERSION#v}",
"description": "Warmbly from the command line: campaigns, contacts, mailboxes, inbox",
"homepage": "https://warmbly.com",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "${BASE}/warmbly_windows_amd64.zip",
"hash": "$(sum_for warmbly_windows_amd64.zip)"
},
"arm64": {
"url": "${BASE}/warmbly_windows_arm64.zip",
"hash": "$(sum_for warmbly_windows_arm64.zip)"
}
},
"bin": "warmbly.exe",
"checkver": {
"github": "https://github.com/${REPO}"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/${REPO}/releases/download/v\$version/warmbly_windows_amd64.zip"
},
"arm64": {
"url": "https://github.com/${REPO}/releases/download/v\$version/warmbly_windows_arm64.zip"
}
}
}
}
EOF
echo
echo "wrote $OUT/ (archives, checksums.txt, warmbly.rb, warmbly.json)"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
#
# Checks the CLI installer served at https://warmbly.com/cli.sh, and its
# Windows counterpart at /cli.ps1.
#
# Both are served verbatim out of site/public, so this runs against the exact
# bytes a `curl -fsSL https://warmbly.com/cli.sh | sh` executes:
#
# * it parses as POSIX sh, in dash and not only in bash
# * shellcheck has nothing to say about it
# * --help and --dry-run work with no network and no terminal, because that
# is how someone reads it before trusting it, and --dry-run writes nothing
# * a real install against a local mirror produces a working binary, puts it
# on PATH and writes completions
# * a tampered checksum stops the install rather than warning about it
# * --uninstall removes exactly what it wrote and nothing else
# * the platforms it will download match the ones we actually build
# * the published checksum matches, so "download, verify, read, run" verifies
set -euo pipefail
cd "$(dirname "$0")/.."
SCRIPT=site/public/cli.sh
PS_SCRIPT=site/public/cli.ps1
SUMFILE=site/public/cli.sh.sha256
fail() { printf '\n\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
pass() { printf '\033[32m✓\033[0m %s\n' "$*"; }
# macOS ships shasum and not GNU sha256sum; this check has to run there too.
sha256_all() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@"; else shasum -a 256 "$@"; fi
}
sha256_verify() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum -c "$@"; else shasum -a 256 -c "$@"; fi
}
[[ -f $SCRIPT ]] || fail "$SCRIPT is missing"
[[ -f $PS_SCRIPT ]] || fail "$PS_SCRIPT is missing"
# The script is executed by whatever /bin/sh is on the machine, which on Debian
# and Ubuntu is dash. Checking with bash alone would let a bashism through to
# exactly the hosts this is aimed at.
if command -v dash >/dev/null 2>&1; then
dash -n "$SCRIPT" || fail "the installer is not valid POSIX sh (dash -n)"
pass "parses as POSIX sh"
else
sh -n "$SCRIPT" || fail "the installer does not parse"
pass "parses (dash not installed; POSIX check was approximate)"
fi
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -s sh "$SCRIPT" || fail "shellcheck found problems in the installer"
pass "shellcheck clean ($(shellcheck --version | awk '/^version:/ {print $2}'))"
else
echo "· shellcheck not installed; skipped"
fi
# --help must work before anything is set up, which is where an unbound
# variable under set -u would otherwise hide.
sh "$SCRIPT" --help >/dev/null || fail "--help failed"
pass "--help works"
# A rejected flag has to print its message. This used to abort with
# "C_RED: unbound variable" instead, because parse_args runs before the colours
# are set and `set -u` turns an unset variable into a fatal error. Only --help
# and --dry-run were exercised, so nothing caught it.
for bad in --nonsense --dir; do
# The exit status matters as much as the message: a script that explains the
# problem and then exits 0 tells every caller the install succeeded.
if out=$(sh "$SCRIPT" "$bad" 2>&1); then
fail "$bad exited 0; a rejected flag has to fail"
fi
case "$out" in
*"unbound variable"*) fail "$bad aborted with an unbound variable instead of an error message" ;;
esac
case "$out" in
*"unknown option"*|*"needs a path"*) ;;
*) fail "$bad did not explain itself:
$out" ;;
esac
done
pass "a rejected flag explains itself"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# --dry-run reaches its end with no network and, above all, writes nothing.
HOME="$work/dryhome" SHELL=/bin/bash sh "$SCRIPT" --dry-run --no-color --dir "$work/dryhome/bin" >/dev/null 2>&1 \
|| fail "--dry-run failed"
[[ ! -e "$work/dryhome" ]] || fail "--dry-run created $work/dryhome; it must write nothing"
pass "--dry-run runs and writes nothing"
# Every platform the installer will ask for has to be one we build, and the
# other way round. A mismatch is a 404 for whoever runs it on that machine.
script_platforms=$(sed -n 's/^PLATFORMS="\(.*\)"$/\1/p' "$SCRIPT" | tr ' ' '\n' | sort)
build_platforms=$(sed -n 's/^PLATFORMS="\(.*\)"$/\1/p' scripts/build-cli.sh | tr ' ' '\n' | sed 's|/|_|' | grep -v '^windows' | sort)
if [[ "$script_platforms" != "$build_platforms" ]]; then
fail "cli.sh and scripts/build-cli.sh disagree about platforms:
installer builds for:
$script_platforms
release builds:
$build_platforms"
fi
pass "installer and release agree on platforms"
# ─────────────────────────────────────────────────────────────────────────
# A real install, against a mirror on disk. file:// keeps this offline, which
# is what lets it run in CI without reaching GitHub.
# ─────────────────────────────────────────────────────────────────────────
mirror="$work/mirror"
mkdir -p "$mirror" "$work/stage/completions"
# A stand-in for the real binary: the installer only needs something that runs
# and answers `version`, and building the real CLI here would make this check
# a minute slower for nothing.
cat > "$work/stage/warmbly" <<'STUB'
#!/bin/sh
[ "${1:-}" = version ] && echo "warmbly v0.0.0-test (test)" && exit 0
exit 0
STUB
chmod +x "$work/stage/warmbly"
echo "# completions" > "$work/stage/completions/warmbly.bash"
echo "# completions" > "$work/stage/completions/warmbly.zsh"
echo "# completions" > "$work/stage/completions/warmbly.fish"
cp LICENSE "$work/stage/" 2>/dev/null || echo license > "$work/stage/LICENSE"
host_os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$(uname -m)" in
x86_64|amd64) host_arch=amd64 ;;
arm64|aarch64) host_arch=arm64 ;;
*) host_arch=amd64 ;;
esac
asset="warmbly_${host_os}_${host_arch}.tar.gz"
tar -czf "$mirror/$asset" -C "$work/stage" .
( cd "$mirror" && sha256_all "$asset" > checksums.txt )
home="$work/home"
mkdir -p "$home"
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" \
--base-url "file://$mirror" --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "installing from a local mirror failed"
[[ -x "$home/bin/warmbly" ]] || fail "the installer did not produce $home/bin/warmbly"
[[ "$("$home/bin/warmbly" version)" == "warmbly v0.0.0-test (test)" ]] || fail "the installed binary does not run"
pass "installs a working binary from a mirror"
grep -q 'warmbly CLI installer' "$home/.bash_profile" 2>/dev/null || grep -q 'warmbly CLI installer' "$home/.bashrc" 2>/dev/null \
|| fail "the installer did not put the install directory on PATH"
pass "puts the install directory on PATH"
[[ -f "$home/.local/share/bash-completion/completions/warmbly" ]] || fail "no bash completions were written"
pass "writes shell completions"
# A second run must not append the PATH line again.
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" \
--base-url "file://$mirror" --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "the second install run failed"
occurrences=$(grep -c 'warmbly CLI installer' "$home/.bash_profile" 2>/dev/null || true)
[[ "${occurrences:-0}" -le 1 ]] || fail "re-running appended the PATH line again ($occurrences times)"
pass "re-running is idempotent"
# A tampered archive must stop the install, not warn about it.
bad="$work/badmirror"
mkdir -p "$bad"
cp "$mirror/$asset" "$bad/"
sed 's/^[0-9a-f]\{64\}/0000000000000000000000000000000000000000000000000000000000000000/' \
"$mirror/checksums.txt" > "$bad/checksums.txt"
badhome="$work/badhome"
if HOME="$badhome" sh "$SCRIPT" --base-url "file://$bad" --dir "$badhome/bin" --no-color >/dev/null 2>&1; then
fail "a checksum mismatch did not stop the install"
fi
[[ ! -e "$badhome/bin/warmbly" ]] || fail "a checksum mismatch still installed the binary"
pass "refuses to install on a checksum mismatch"
# --uninstall removes what it wrote, and leaves the credentials alone.
mkdir -p "$home/.config/warmbly"
echo "token" > "$home/.config/warmbly/hosts.yml"
HOME="$home" SHELL=/bin/bash sh "$SCRIPT" --uninstall --dir "$home/bin" --no-color >/dev/null 2>&1 \
|| fail "--uninstall failed"
[[ ! -e "$home/bin/warmbly" ]] || fail "--uninstall left the binary behind"
[[ ! -e "$home/.local/share/bash-completion/completions/warmbly" ]] || fail "--uninstall left completions behind"
[[ -f "$home/.config/warmbly/hosts.yml" ]] || fail "--uninstall removed the credentials; it must not"
pass "--uninstall removes the binary and completions, and keeps credentials"
# ─────────────────────────────────────────────────────────────────────────
# The Windows half. Ubuntu runners ship pwsh, so this is a real parse there.
# ─────────────────────────────────────────────────────────────────────────
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -Command "
\$errors = \$null
[System.Management.Automation.Language.Parser]::ParseFile('$PWD/$PS_SCRIPT', [ref]\$null, [ref]\$errors) | Out-Null
if (\$errors) { \$errors | ForEach-Object { Write-Host \$_ }; exit 1 }
" || fail "$PS_SCRIPT does not parse as PowerShell"
pass "cli.ps1 parses as PowerShell"
else
echo "· pwsh not installed; skipped the PowerShell parse"
fi
# ─────────────────────────────────────────────────────────────────────────
# The published checksum, which is what makes "download, verify, read, run" a
# real alternative to piping into a shell.
# ─────────────────────────────────────────────────────────────────────────
[[ -f $SUMFILE ]] || fail "$SUMFILE is missing. Run: make cli-sha"
( cd site/public && sha256_verify "$(basename "$SUMFILE")" >/dev/null ) \
|| fail "$SUMFILE does not match $SCRIPT. Run: make cli-sha"
pass "published checksum matches"
printf '\n\033[32mAll CLI installer checks passed.\033[0m\n'
+268
View File
@@ -0,0 +1,268 @@
<#
.SYNOPSIS
Installs the warmbly CLI on Windows.
.DESCRIPTION
One static binary, no toolchain, no admin rights. Downloads the archive for
this machine's architecture from the GitHub release, checks it against the
published checksum, unpacks it into a per-user directory and puts that
directory on the user PATH.
Re-running it upgrades in place.
.EXAMPLE
irm https://warmbly.com/cli.ps1 | iex
.EXAMPLE
& ([scriptblock]::Create((irm https://warmbly.com/cli.ps1))) -Version v1.4.0
.EXAMPLE
& ([scriptblock]::Create((irm https://warmbly.com/cli.ps1))) -Uninstall
.LINK
https://docs.warmbly.com/api/cli/
#>
[CmdletBinding()]
param(
# Where the binary goes. Defaults to a per-user directory so nothing here
# needs an elevated shell.
[string]$Dir = $env:WARMBLY_INSTALL_DIR,
# A release tag to pin, for example v1.4.0. Defaults to the newest release.
[string]$Version = $env:WARMBLY_CLI_VERSION,
# Download from a mirror of the release assets instead of GitHub, for an
# egress-restricted network.
[string]$BaseUrl = $env:WARMBLY_CLI_BASE_URL,
# Leave the user PATH alone.
[switch]$NoModifyPath,
# Print what would happen and change nothing.
[switch]$DryRun,
# Remove the binary and its PATH entry.
[switch]$Uninstall,
# Reinstall even when the version already matches.
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$Repo = 'warmbly/warmbly'
$Releases = "https://github.com/$Repo/releases"
$Docs = 'https://docs.warmbly.com/api/cli/'
function Write-Step { param($m) Write-Host "> $m" -ForegroundColor Cyan }
function Write-Ok { param($m) Write-Host "$m" -ForegroundColor Green }
function Write-Warn { param($m) Write-Host "! $m" -ForegroundColor Yellow }
function Write-Fail { param($m) Write-Host "$m" -ForegroundColor Red; exit 1 }
# Windows on ARM runs amd64 binaries under emulation, but a native build is
# published, so the architecture is read rather than assumed.
function Get-Arch {
$arch = $env:PROCESSOR_ARCHITECTURE
if ($env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 }
switch ($arch) {
'AMD64' { return 'amd64' }
'ARM64' { return 'arm64' }
default {
Write-Fail @"
No published build for $arch.
We publish amd64 and arm64. Build from source with:
go install github.com/$Repo/cmd/cli@latest
"@
}
}
}
function Get-AssetUrl {
param($Name)
if ($BaseUrl) { return "$($BaseUrl.TrimEnd('/'))/$Name" }
if ($Version) { return "$Releases/download/$Version/$Name" }
return "$Releases/latest/download/$Name"
}
function Get-InstallDir {
if ($Dir) { return $Dir }
return (Join-Path $env:LOCALAPPDATA 'Warmbly\bin')
}
# The user PATH is read from the registry rather than from $env:PATH, because
# the session copy already has machine entries merged in and writing that back
# would move machine-wide entries into the user scope.
function Add-ToUserPath {
param($Target)
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($null -eq $current) { $current = '' }
$entries = $current -split ';' | Where-Object { $_ -ne '' }
if ($entries -contains $Target) {
Write-Ok "$Target is already on your PATH"
return
}
if ($NoModifyPath) {
Write-Warn "$Target is not on your PATH. Add it yourself, or re-run without -NoModifyPath."
return
}
if ($DryRun) {
Write-Host " would add $Target to the user PATH"
return
}
$updated = (@($entries) + $Target) -join ';'
[Environment]::SetEnvironmentVariable('Path', $updated, 'User')
# The registry change reaches new processes only, so this session gets the
# entry too. Without it, the very next command in this window fails.
$env:Path = "$env:Path;$Target"
Write-Ok "added $Target to your PATH"
Write-Warn 'Open a new terminal for other programs to see it.'
}
function Install-Completions {
param($Source)
$profilePath = $PROFILE.CurrentUserAllHosts
$marker = '# Added by the warmbly CLI installer'
# Reported before any path is built: the dry run has no unpacked archive,
# and Join-Path on an empty path is a terminating error under Stop.
if ($DryRun) {
Write-Host " would add completions to $profilePath"
return
}
$completion = Join-Path $Source 'completions\warmbly.powershell'
if (-not (Test-Path $completion)) { return }
if ((Test-Path $profilePath) -and (Select-String -Path $profilePath -Pattern ([regex]::Escape($marker)) -Quiet)) {
return
}
$dest = Join-Path (Get-InstallDir) 'warmbly.completion.ps1'
Copy-Item $completion $dest -Force
New-Item -ItemType Directory -Force -Path (Split-Path $profilePath) | Out-Null
Add-Content -Path $profilePath -Value "`n$marker`n. `"$dest`""
Write-Ok "wrote completions and referenced them from $profilePath"
}
function Invoke-Uninstall {
$target = Get-InstallDir
$exe = Join-Path $target 'warmbly.exe'
$removed = $false
if (Test-Path $exe) {
if ($DryRun) { Write-Host "would remove $exe" }
else { Remove-Item $exe -Force; Write-Ok "removed $exe" }
$removed = $true
}
$completion = Join-Path $target 'warmbly.completion.ps1'
if (Test-Path $completion) {
if (-not $DryRun) { Remove-Item $completion -Force }
$removed = $true
}
if (-not $DryRun) {
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($current) {
$kept = $current -split ';' | Where-Object { $_ -ne '' -and $_ -ne $target }
[Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User')
}
}
if (-not $removed) { Write-Warn "nothing to remove: no warmbly.exe in $target" }
$config = Join-Path $env:APPDATA 'warmbly'
if (Test-Path $config) {
Write-Host ''
Write-Host "Your sign-ins are still in $config."
Write-Host "Remove them with: Remove-Item -Recurse '$config'"
}
}
function Invoke-Install {
$arch = Get-Arch
$target = Get-InstallDir
$asset = "warmbly_windows_$arch.zip"
Write-Step 'Installing the warmbly CLI'
Write-Host " platform: windows/$arch"
Write-Host " version: $(if ($Version) { $Version } else { 'latest' })"
Write-Host " into: $target"
Write-Host ''
$exe = Join-Path $target 'warmbly.exe'
if ((Test-Path $exe) -and $Version -and -not $Force) {
$current = (& $exe version 2>$null | Select-Object -First 1) -split ' ' | Select-Object -Index 1
if ($current -eq $Version) {
Write-Ok "warmbly $current is already installed in $target"
return
}
}
if ($DryRun) {
Write-Host "would download $(Get-AssetUrl $asset)"
Write-Host "would verify it against $(Get-AssetUrl 'checksums.txt')"
Write-Host "would install $exe"
Install-Completions ''
Add-ToUserPath $target
return
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("warmbly-" + [guid]::NewGuid())
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
Write-Step "Downloading $asset"
$zip = Join-Path $tmp $asset
try {
Invoke-WebRequest -Uri (Get-AssetUrl $asset) -OutFile $zip -UseBasicParsing
} catch {
Write-Fail "could not download $(Get-AssetUrl $asset)`nIf you pinned -Version, check the tag exists: $Releases"
}
# The checksum is why this is safer than a bare download: a truncated
# transfer and a tampered one are indistinguishable to Expand-Archive.
try {
$sums = Join-Path $tmp 'checksums.txt'
Invoke-WebRequest -Uri (Get-AssetUrl 'checksums.txt') -OutFile $sums -UseBasicParsing
$want = (Select-String -Path $sums -Pattern ([regex]::Escape($asset)) | Select-Object -First 1).Line -split '\s+' | Select-Object -First 1
$got = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if (-not $want) {
Write-Warn "checksums.txt has no entry for $asset; continuing without verification"
} elseif ($want.ToLower() -ne $got) {
Write-Fail "checksum mismatch for $asset.`n expected $want`n got $got`nNothing was installed."
} else {
Write-Ok 'checksum verified'
}
} catch {
Write-Warn 'could not fetch checksums.txt; continuing without verification'
}
Write-Step 'Unpacking'
$unpacked = Join-Path $tmp 'x'
Expand-Archive -Path $zip -DestinationPath $unpacked -Force
$source = Join-Path $unpacked 'warmbly.exe'
if (-not (Test-Path $source)) { Write-Fail 'the archive did not contain warmbly.exe' }
New-Item -ItemType Directory -Force -Path $target | Out-Null
Copy-Item $source $exe -Force
$installed = (& $exe version 2>$null | Select-Object -First 1)
Write-Ok "installed $installed to $exe"
Install-Completions $unpacked
Add-ToUserPath $target
Write-Host ''
Write-Host 'Next: warmbly auth login'
Write-Host "Docs: $Docs"
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
}
if ($Uninstall) { Invoke-Uninstall } else { Invoke-Install }
+555
View File
@@ -0,0 +1,555 @@
#!/bin/sh
#
# curl -fsSL https://warmbly.com/cli.sh | sh
#
# Installs the `warmbly` CLI on this machine: one static binary, no Go
# toolchain, no package manager, no root. It downloads the archive for your
# platform from the GitHub release, checks it against the published checksum,
# and puts the binary somewhere on your PATH.
#
# sh cli.sh --help every flag, and the environment variable for each
# sh cli.sh --dry-run print exactly what it would do, touch nothing
# sh cli.sh --uninstall remove the binary and the completions it wrote
#
# What it does, in full:
#
# * detects your OS and CPU, and stops with a real message if we publish no
# build for it rather than downloading something that cannot run
# * resolves the newest release (or the one you pin with --version)
# * downloads warmbly_<os>_<arch>.tar.gz and checksums.txt, and REFUSES to
# install if the two disagree
# * installs to ~/.local/bin by default, which needs no sudo. Nothing else on
# your system is touched
# * writes shell completions, and tells you the one line to add to your shell
# profile if the install directory is not already on PATH
#
# Re-running it upgrades in place and says so when there is nothing to do.
#
# Verify before running, if you would rather:
#
# curl -fsSLO https://warmbly.com/cli.sh
# curl -fsSLO https://warmbly.com/cli.sh.sha256
# sha256sum -c cli.sh.sha256
# less cli.sh && sh cli.sh
#
# https://docs.warmbly.com/api/cli/
set -eu
# ─────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────
REPO="warmbly/warmbly"
BIN="warmbly"
DOCS="https://docs.warmbly.com/api/cli/"
RELEASES="https://github.com/${REPO}/releases"
# Every platform scripts/build-cli.sh publishes. The two lists have to agree:
# a platform here with no archive downloads a 404, and one missing here is a
# build nobody can install.
PLATFORMS="darwin_amd64 darwin_arm64 linux_amd64 linux_arm64"
# ─────────────────────────────────────────────────────────────────────────
# Options. Every one is also an environment variable, so the same install runs
# from Ansible, cloud-init, a Dockerfile or an agent with no keyboard.
# ─────────────────────────────────────────────────────────────────────────
DIR=${WARMBLY_INSTALL_DIR:-}
VERSION=${WARMBLY_CLI_VERSION:-}
# Where the archives come from. Overridable so an air-gapped or
# egress-restricted network can mirror the release assets internally and still
# use this exact script.
BASE_URL=${WARMBLY_CLI_BASE_URL:-}
NO_MODIFY_PATH=${WARMBLY_NO_MODIFY_PATH:-}
NO_COMPLETIONS=${WARMBLY_NO_COMPLETIONS:-}
DRY_RUN=""
UNINSTALL=""
FORCE=""
QUIET=""
USE_COLOR=1
# ─────────────────────────────────────────────────────────────────────────
# Output
#
# The colour variables are defined empty here rather than only in
# setup_colors, because parse_args runs first and can call die: under `set -u`
# an unset C_RED turns a "unknown option" message into an unbound-variable
# error, which is what a mistyped flag would have printed.
# ─────────────────────────────────────────────────────────────────────────
C_RESET=""; C_DIM=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_CYAN=""
setup_colors() {
if [ -n "$USE_COLOR" ] && [ -t 2 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET=$(printf '\033[0m')
C_DIM=$(printf '\033[2m')
C_BOLD=$(printf '\033[1m')
C_RED=$(printf '\033[31m')
C_GREEN=$(printf '\033[32m')
C_YELLOW=$(printf '\033[33m')
C_CYAN=$(printf '\033[36m')
else
C_RESET=""; C_DIM=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_CYAN=""
fi
}
say() { [ -n "$QUIET" ] || printf '%s\n' "$*" >&2; }
step() { [ -n "$QUIET" ] || printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$*" >&2; }
ok() { [ -n "$QUIET" ] || printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2; }
warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
die() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
usage() {
cat <<EOF
Install the warmbly CLI.
Usage:
curl -fsSL https://warmbly.com/cli.sh | sh
curl -fsSL https://warmbly.com/cli.sh | sh -s -- [flags]
Flags:
--dir PATH Where to install the binary (WARMBLY_INSTALL_DIR)
Default: \$HOME/.local/bin
--version VERSION Install a specific release tag (WARMBLY_CLI_VERSION)
Default: the newest release
--base-url URL Download from a mirror of the release
assets instead of GitHub (WARMBLY_CLI_BASE_URL)
--no-modify-path Never touch a shell profile (WARMBLY_NO_MODIFY_PATH)
--no-completions Do not write shell completions (WARMBLY_NO_COMPLETIONS)
--force Reinstall even if the version already matches
--uninstall Remove the binary and its completions
--dry-run Print what would happen, change nothing
--quiet Only errors
--no-color Never colourise output
--help This
Examples:
curl -fsSL https://warmbly.com/cli.sh | sh
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --dir /usr/local/bin
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --version v1.4.0
curl -fsSL https://warmbly.com/cli.sh | sh -s -- --uninstall
After installing, sign in:
warmbly auth login
Docs: ${DOCS}
EOF
}
parse_args() {
while [ $# -gt 0 ]; do
case $1 in
--dir) shift; [ $# -gt 0 ] || die "--dir needs a path"; DIR=$1 ;;
--dir=*) DIR=${1#*=} ;;
--version) shift; [ $# -gt 0 ] || die "--version needs a release tag"; VERSION=$1 ;;
--version=*) VERSION=${1#*=} ;;
--base-url) shift; [ $# -gt 0 ] || die "--base-url needs a URL"; BASE_URL=$1 ;;
--base-url=*) BASE_URL=${1#*=} ;;
--no-modify-path) NO_MODIFY_PATH=1 ;;
--no-completions) NO_COMPLETIONS=1 ;;
--force) FORCE=1 ;;
--uninstall) UNINSTALL=1 ;;
--dry-run) DRY_RUN=1 ;;
--quiet|-q) QUIET=1 ;;
--no-color) USE_COLOR="" ;;
--help|-h) usage; exit 0 ;;
*) usage >&2; die "unknown option $1" ;;
esac
shift
done
}
# ─────────────────────────────────────────────────────────────────────────
# Platform
# ─────────────────────────────────────────────────────────────────────────
detect_platform() {
os=$(uname -s 2>/dev/null || echo unknown)
arch=$(uname -m 2>/dev/null || echo unknown)
case $os in
Linux) OS=linux ;;
Darwin) OS=darwin ;;
MINGW*|MSYS*|CYGWIN*)
die "this script installs the Unix build.
On Windows run this in PowerShell instead:
irm https://warmbly.com/cli.ps1 | iex" ;;
*) die "no published build for $os. Build from source with: go install github.com/${REPO}/cmd/cli@latest" ;;
esac
case $arch in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) die "no published build for $arch on $OS.
We publish amd64 and arm64. Build from source with:
go install github.com/${REPO}/cmd/cli@latest" ;;
esac
TARGET="${OS}_${ARCH}"
for known in $PLATFORMS; do
if [ "$known" = "$TARGET" ]; then
return 0
fi
done
die "no published build for $TARGET"
}
# fetch writes a URL to a file. curl and wget are both accepted because a
# minimal container image has exactly one of them and it is never the one you
# assumed.
fetch() {
url=$1
dest=$2
if [ -n "$DOWNLOADER" ] && [ "$DOWNLOADER" = curl ]; then
curl -fsSL --retry 3 --retry-delay 1 -o "$dest" "$url"
else
wget -q -O "$dest" "$url"
fi
}
require_downloader() {
if command -v curl >/dev/null 2>&1; then
DOWNLOADER=curl
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER=wget
else
die "neither curl nor wget is installed, so there is nothing to download with"
fi
}
# sha256_of prints a file's checksum with whichever tool the host has. macOS
# ships shasum, Linux ships sha256sum, Alpine ships both or neither.
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$1" | awk '{print $NF}'
else
echo ""
fi
}
# ─────────────────────────────────────────────────────────────────────────
# Install directory
# ─────────────────────────────────────────────────────────────────────────
# A literal tilde is what this matches: someone who typed --dir '~/bin' inside
# quotes meant their home directory, not a folder called "~".
# shellcheck disable=SC2088
expand_tilde() {
case $DIR in
"~/"*) DIR="${HOME}/${DIR#\~/}" ;;
esac
}
# resolve_dir picks where the binary goes. ~/.local/bin is the default because
# it needs no sudo and is on PATH by default on most modern distributions;
# piping an installer into a shell should never need root.
resolve_dir() {
if [ -z "$DIR" ]; then
DIR="${HOME:-/root}/.local/bin"
fi
expand_tilde
}
# on_path answers whether DIR is already searched, so we only talk about shell
# profiles when there is a real problem to solve.
on_path() {
case ":${PATH}:" in
*":${DIR}:"*) return 0 ;;
*) return 1 ;;
esac
}
# profile_file is the file a login shell reads, chosen from $SHELL rather than
# the shell running this script: this runs under sh no matter what the person
# actually uses.
profile_file() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;;
bash)
if [ -f "$HOME/.bashrc" ]; then
printf '%s' "$HOME/.bashrc"
else
printf '%s' "$HOME/.bash_profile"
fi ;;
fish) printf '%s' "$HOME/.config/fish/config.fish" ;;
*) printf '%s' "$HOME/.profile" ;;
esac
}
# The single quotes below are the point: $PATH has to reach the profile
# unexpanded, so it still resolves every time the shell reads it.
# shellcheck disable=SC2016
path_line() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
fish) printf 'fish_add_path %s' "$DIR" ;;
*) printf 'export PATH="%s:$PATH"' "$DIR" ;;
esac
}
# ensure_on_path appends the PATH line to the right profile, once. The marker
# comment is what makes a second run a no-op instead of a growing file.
ensure_on_path() {
if on_path; then
return 0
fi
line=$(path_line)
if [ -n "$NO_MODIFY_PATH" ]; then
warn "$DIR is not on your PATH. Add this yourself:"
say " $line"
return 0
fi
profile=$(profile_file)
if [ -n "$DRY_RUN" ]; then
say " would add to $profile: $line"
return 0
fi
if [ -f "$profile" ] && grep -q "warmbly CLI" "$profile" 2>/dev/null; then
ok "$profile already has the PATH line"
else
mkdir -p "$(dirname "$profile")"
{
printf '\n# Added by the warmbly CLI installer\n'
printf '%s\n' "$line"
} >> "$profile"
ok "added $DIR to your PATH in $profile"
fi
warn "open a new terminal, or run: $line"
}
# ─────────────────────────────────────────────────────────────────────────
# Completions
# ─────────────────────────────────────────────────────────────────────────
# completion_dir is where the shell looks without any configuration. When there
# is no such place we say nothing rather than writing a file that is never read.
completion_dir() {
shell_name=$(basename "${SHELL:-sh}")
case $shell_name in
bash)
if [ -d "$HOME/.local/share/bash-completion/completions" ] || [ "$1" = create ]; then
printf '%s' "$HOME/.local/share/bash-completion/completions/warmbly"
fi ;;
zsh)
printf '%s' "${ZDOTDIR:-$HOME}/.zfunc/_warmbly" ;;
fish)
printf '%s' "$HOME/.config/fish/completions/warmbly.fish" ;;
*) printf '' ;;
esac
}
install_completions() {
if [ -n "$NO_COMPLETIONS" ]; then
return 0
fi
shell_name=$(basename "${SHELL:-sh}")
src=""
case $shell_name in
bash) src="$1/completions/warmbly.bash" ;;
zsh) src="$1/completions/warmbly.zsh" ;;
fish) src="$1/completions/warmbly.fish" ;;
*) return 0 ;;
esac
dest=$(completion_dir create)
[ -n "$dest" ] || return 0
# The dry run has no unpacked archive to copy from, so it reports the
# destination rather than testing for a source that cannot exist yet.
if [ -n "$DRY_RUN" ]; then
say " would write $shell_name completions to $dest"
return 0
fi
[ -f "$src" ] || return 0
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
ok "wrote $shell_name completions to $dest"
if [ "$shell_name" = zsh ]; then
say " ${C_DIM}zsh needs ~/.zfunc on its fpath: add \`fpath+=~/.zfunc\` above compinit${C_RESET}"
fi
return 0
}
# ─────────────────────────────────────────────────────────────────────────
# Uninstall
# ─────────────────────────────────────────────────────────────────────────
do_uninstall() {
resolve_dir
target="$DIR/$BIN"
removed=""
if [ -f "$target" ]; then
if [ -n "$DRY_RUN" ]; then
say "would remove $target"
else
rm -f "$target"
ok "removed $target"
fi
removed=1
fi
for c in "$HOME/.local/share/bash-completion/completions/warmbly" \
"${ZDOTDIR:-$HOME}/.zfunc/_warmbly" \
"$HOME/.config/fish/completions/warmbly.fish"; do
if [ -f "$c" ]; then
if [ -n "$DRY_RUN" ]; then
say "would remove $c"
else
rm -f "$c"
ok "removed $c"
fi
removed=1
fi
done
if [ -z "$removed" ]; then
warn "nothing to remove: no warmbly found in $DIR"
fi
# Deliberately left alone: it holds the credentials, and someone
# reinstalling in a minute should not have to sign in again.
if [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/warmbly" ]; then
say ""
say "Your sign-ins are still in ${XDG_CONFIG_HOME:-$HOME/.config}/warmbly."
say "Remove them with: rm -rf ${XDG_CONFIG_HOME:-$HOME/.config}/warmbly"
fi
return 0
}
# ─────────────────────────────────────────────────────────────────────────
# Install
# ─────────────────────────────────────────────────────────────────────────
# archive_url builds the download URL. The version-less asset names are what
# let "latest" resolve with no GitHub API call, so the install works on a CI
# runner whose IP has already spent the unauthenticated rate limit.
archive_url() {
name=$1
if [ -n "$BASE_URL" ]; then
printf '%s/%s' "${BASE_URL%/}" "$name"
elif [ -n "$VERSION" ]; then
printf '%s/download/%s/%s' "$RELEASES" "$VERSION" "$name"
else
printf '%s/latest/download/%s' "$RELEASES" "$name"
fi
}
installed_version() {
if [ -x "$DIR/$BIN" ]; then
"$DIR/$BIN" version 2>/dev/null | head -1 | awk '{print $2}'
fi
}
do_install() {
detect_platform
resolve_dir
archive="warmbly_${TARGET}.tar.gz"
url=$(archive_url "$archive")
sums_url=$(archive_url "checksums.txt")
step "Installing the warmbly CLI"
say " platform: ${OS}/${ARCH}"
say " version: ${VERSION:-latest}"
say " into: ${DIR}"
say ""
current=$(installed_version)
if [ -n "$current" ] && [ -z "$FORCE" ] && [ -n "$VERSION" ] && [ "$current" = "$VERSION" ]; then
ok "warmbly $current is already installed in $DIR"
say " ${C_DIM}--force reinstalls it anyway${C_RESET}"
return 0
fi
if [ -n "$DRY_RUN" ]; then
say "would download $url"
say "would verify it against $sums_url"
say "would install $DIR/$BIN"
install_completions "" || true
ensure_on_path
return 0
fi
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t warmbly)
# The trap is set before the first write, so an interrupted install leaves
# nothing behind in /tmp.
trap 'rm -rf "$tmp"' EXIT INT TERM
step "Downloading $archive"
if ! fetch "$url" "$tmp/$archive"; then
die "could not download $url
If you pinned --version, check the tag exists: ${RELEASES}"
fi
# The checksum is the whole reason this is safer than a bare curl into tar:
# a truncated download and a tampered one look the same to tar.
if fetch "$sums_url" "$tmp/checksums.txt" 2>/dev/null; then
want=$(awk -v f="$archive" '$2 == f || $2 == "*"f { print $1 }' "$tmp/checksums.txt" | head -1)
got=$(sha256_of "$tmp/$archive")
if [ -z "$want" ]; then
warn "checksums.txt has no entry for $archive; continuing without verification"
elif [ -z "$got" ]; then
warn "no sha256 tool on this machine, so the download was not verified"
elif [ "$want" != "$got" ]; then
die "checksum mismatch for $archive.
expected $want
got $got
Nothing was installed. Try again, and if it happens twice report it: ${RELEASES}"
else
ok "checksum verified"
fi
else
warn "could not fetch checksums.txt; continuing without verification"
fi
step "Unpacking"
mkdir -p "$tmp/x"
tar -xzf "$tmp/$archive" -C "$tmp/x" || die "the archive could not be unpacked"
[ -f "$tmp/x/$BIN" ] || die "the archive did not contain $BIN"
mkdir -p "$DIR" 2>/dev/null || die "could not create $DIR.
Pick somewhere writable with --dir, for example: --dir \$HOME/bin"
# install(1) is not on every minimal image, so this is cp plus chmod, done
# to a temporary name and moved into place: replacing a running binary with
# a rename is atomic, overwriting one in place is not.
cp "$tmp/x/$BIN" "$DIR/.$BIN.new" || die "could not write to $DIR.
Pick somewhere writable with --dir, or re-run with sudo if $DIR is system-owned."
chmod 0755 "$DIR/.$BIN.new"
mv -f "$DIR/.$BIN.new" "$DIR/$BIN"
version_now=$("$DIR/$BIN" version 2>/dev/null | head -1 || echo "")
ok "installed ${version_now:-warmbly} to $DIR/$BIN"
install_completions "$tmp/x" || true
ensure_on_path
say ""
say "${C_BOLD}Next:${C_RESET} warmbly auth login"
say "${C_DIM}Docs: ${DOCS}${C_RESET}"
return 0
}
main() {
parse_args "$@"
setup_colors
require_downloader
if [ -n "$UNINSTALL" ]; then
do_uninstall
return 0
fi
do_install
}
main "$@"
+1
View File
@@ -0,0 +1 @@
bc129c17774a4c45bb08c5d6228beedf199ce384aa3a16cb798ebe5c75e1861b cli.sh
+153
View File
@@ -0,0 +1,153 @@
---
name: warmbly-cli
description: Use the `warmbly` CLI to drive Warmbly as a signed-in user - sign in with `warmbly auth login`, then work with campaigns, contacts, mailboxes, the unified inbox, analytics, webhooks and the live event stream on the hosted service or any self-hosted instance. Use whenever the task is to operate Warmbly the product from a terminal or a script and a `warmbly` binary is available. For instance recovery and accounts (database-level), use warmbly-ops instead.
---
# Driving Warmbly through the `warmbly` CLI
`warmbly` is the customer CLI: it signs in as a person, holds one credential
per host in `~/.config/warmbly`, and speaks only the public REST API. Every
command is bounded by the scopes the sign-in approved.
If the binary is not on PATH, install it without a toolchain or root:
```bash
curl -fsSL https://warmbly.com/cli.sh | sh # macOS, Linux
irm https://warmbly.com/cli.ps1 | iex # Windows
```
Add `-s -- --dir <path>` to place it somewhere specific. It is also inside the
backend image on a self-hosted instance (`docker compose -p warmbly exec
backend warmbly ...`).
It is not `warmblyctl`. That one talks to Postgres and exists for recovery and
accounts (the `warmbly-ops` skill). If both are available and the task is
product work, use `warmbly`.
## Getting authenticated
Check first, because a non-interactive agent cannot complete a browser flow:
```bash
warmbly auth status
```
- **Signed in** (exit 0): carry on.
- **Not signed in** (exit 4): you need a credential. In order of preference:
1. `WARMBLY_TOKEN` in the environment. It overrides everything and is never
written to disk, so it is the right answer for a script or a CI job.
2. `echo "$KEY" | warmbly auth login --with-token`, when a key was supplied.
3. `warmbly auth login`, which needs a human at a browser. Print the code and
the URL it shows and hand back to the user; do not sit in the poll loop
waiting for something only they can do.
Set `WARMBLY_HOST` (or `--host`) for a self-hosted instance, and
`WARMBLY_API_URL` only when the API is not at `api.<host>`.
Exit code 4 always means the credential: missing, rejected, or short a scope.
`warmbly auth status` names which, and where the token came from.
## Output: always ask for JSON
Tables are for humans. Every command takes `--json`, and output is JSON
automatically when stdout is not a terminal, but pass it explicitly so the
shape does not depend on how you were invoked.
```bash
warmbly campaign list --json
warmbly campaign list --json --all # every page, cursor followed
warmbly contact list --json --limit 100
```
Lists are `{"data": [...], "pagination": {"next_cursor", "has_more"}}`. Page
with `--cursor <next_cursor>`, or let `--all` do it. Cursors are opaque, never
construct one.
## Command map
`warmbly <command> --help` lists subcommands; `warmbly <command> <sub> --help`
gives the arguments and flags. Ids are positional, not flags.
| Command | Covers |
|---|---|
| `status` | one call for "what is happening": mailboxes needing attention, what is sending, what is unread |
| `campaign` | list, view, create, edit, delete, steps, senders, segments, preflight, test, start, stop, logs |
| `contact` | list, view, create, edit, delete, lookup, timeline, emails, notes, import, export, verify |
| `mailbox` | list, view, edit, check, sync, behavior, warmup, hold, release, send |
| `inbox` | list, view, thread, read, reply, compose, drafts, scheduled, snooze |
| `suppression` | the list of addresses and domains that get no campaign mail |
| `segment`, `template`, `automation`, `form` | audiences, reply templates, automations, lead capture |
| `deal`, `pipeline`, `task` | the CRM |
| `analytics`, `audit`, `advisor` | numbers, the audit trail, recommendations |
| `webhook`, `key`, `oauth-app`, `integration` | the developer surface |
| `org`, `team`, `settings`, `warmup-routing` | the workspace surface a key can reach |
| `tool` | the AI tool registry, listed and called |
| `events tail` | the live event stream |
| `api` | any endpoint at all |
Anything without a command is reachable through the passthrough. Paths are
relative to `/v1`:
```bash
warmbly api "/campaigns?limit=10" --paginate
warmbly api /contacts -f email=jane@example.com -f first_name=Jane
warmbly api /campaigns/CAMPAIGN_ID -X PATCH -F daily_limit=40
warmbly api /contacts/search -X POST --input filter.json
```
`-f` keeps a string, `-F` guesses the type (`true`, `null`, numbers, `@file`),
`key[sub]=v` nests, repeated `key[]=v` builds an array.
## Sending safety, read before anything that sends
These put real mail on the wire and prompt before doing so:
`campaign start`, `campaign test`, `mailbox send`, `inbox reply`,
`inbox compose`, `inbox approve-draft`. Everything else is safe to run freely.
- With no terminal they refuse rather than send. `--yes` is what proceeds, so
**only pass `--yes` when the user asked for that specific send.** Never add
it globally to be rid of prompts.
- Run `warmbly campaign preflight CAMPAIGN_ID` before `campaign start` and act
on what it reports. It costs nothing and catches missing senders, empty
audiences and broken tracking.
- Never raise a mailbox's daily cap casually. The default is 50 campaign
emails per mailbox per day with 600 seconds between sends; a fresh mailbox
starts around 10-20. Do not go above 50 unless the user asked and the
mailbox has the history to justify it.
- Keep warmup running on mailboxes that campaign. Do not stop warmup because a
campaign started.
- If deliverability shows rising bounces or complaints, stop the campaign and
report. Do not push volume into a degrading mailbox.
## Errors
Failures print the API's `code` and `request_id` to stderr. Branch on `code`
(`not_found`, `forbidden`, `rate_limit_exceeded`, ...) and quote `request_id`
when reporting. On `rate_limit_exceeded`, wait the `Retry-After` it names.
For a write you retry, pass `--idempotency-key <same-key>` so a retry cannot
double-apply. Any unique string works; reuse it only for the identical retry.
Exit codes: `0` worked, `1` failed, `2` bad command line or a prompt with no
terminal, `4` credential.
## Watching what happens
```bash
warmbly events tail --json --intent EMAIL
```
Streams the live event stream as newline-delimited JSON. Needs a key with
`REALTIME_SUBSCRIBE`. Useful for confirming a send actually went out; give it
`--count N` so it terminates rather than running forever.
## What this CLI cannot do
- Connect a mailbox. That needs OAuth consent or a credential form in a
browser: `warmbly browse mailboxes --no-browser` prints the URL to hand over.
- Manage members, roles, invitations, workspace exports or billing. Every
`/organization/*` and `/subscription/*` route is session-only and refuses an
API key, so there is no command for them. `warmbly browse settings
--no-browser` prints the URL to hand over.
- Create accounts, reset passwords, grant platform admin, back up or restore an
instance. That is `warmblyctl` and the `warmbly-ops` skill.
+500
View File
@@ -0,0 +1,500 @@
// app.warmbly.com/cli?code=XXXX-XXXX — a signed-in member authorizes the
// `warmbly` CLI running on one of their machines. Approving mints an ordinary
// API key in the workspace they pick, which the CLI collects on its next poll.
// Standalone on the auth screen's sky: enter code, review, done.
import React from "react";
import { Link, Navigate, useSearchParams } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import toast from "react-hot-toast";
import {
ArrowLeftIcon,
ArrowRightIcon,
BuildingIcon,
CheckIcon,
ExternalLinkIcon,
KeyRoundIcon,
Loader2Icon,
LockIcon,
MonitorIcon,
TerminalIcon,
XIcon,
} from "lucide-react";
import { Logo } from "@/components/svg";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { WEBSITE_URL } from "@/lib/information";
import getToken from "@/lib/helper/getToken";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import type Organization from "@/lib/api/models/app/organizations/Organization";
import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
import type { CLIAuthCode } from "@/lib/api/models/app/cliauth/CLIAuth";
import { useApproveCLIAuthCode, useCLIAuthCode, useDenyCLIAuthCode } from "@/lib/api/hooks/app/cliauth/useCLIAuth";
const CODE_LENGTH = 8;
function clean(raw: string): string {
return raw.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, CODE_LENGTH);
}
function dashed(code: string): string {
return code.length > 4 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
}
const slide = {
enter: (dir: number) => ({ opacity: 0, x: dir > 0 ? 28 : -28 }),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir > 0 ? -28 : 28 }),
};
const slideTransition = { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
export default function CLIAuthPage() {
if (!getToken()) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
return <Navigate to={`/auth/login?next=${next}`} replace />;
}
return <CLIAuthInner />;
}
function CLIAuthInner() {
const [params] = useSearchParams();
const [code, setCode] = React.useState(() => clean(params.get("code") ?? ""));
const complete = code.length === CODE_LENGTH;
const lookup = useCLIAuthCode(dashed(code));
const orgs = useOrganizations();
const approve = useApproveCLIAuthCode();
const deny = useDenyCLIAuthCode();
const [orgId, setOrgId] = React.useState("");
const [outcome, setOutcome] = React.useState<"approved" | "denied" | null>(null);
const [dir, setDir] = React.useState(1);
React.useEffect(() => {
if (!orgId && orgs.data && orgs.data.length > 0) setOrgId(orgs.data[0].id);
}, [orgs.data, orgId]);
const info = complete ? lookup.data : undefined;
const step: "code" | "review" | "done" = outcome ? "done" : info ? "review" : "code";
const reset = () => {
setDir(-1);
setCode("");
setOutcome(null);
};
const doApprove = async () => {
try {
setDir(1);
await approve.mutateAsync({ code: dashed(code), organizationId: orgId });
setOutcome("approved");
} catch (e) {
toast.error(buildError(e as AppError));
}
};
const doDeny = async () => {
try {
setDir(1);
await deny.mutateAsync(dashed(code));
setOutcome("denied");
} catch (e) {
toast.error(buildError(e as AppError));
}
};
return (
<div className="relative min-h-dvh w-full overflow-hidden flex flex-col items-center justify-center px-4 py-8 sm:py-10">
<div className="absolute inset-0" aria-hidden="true">
<div className="sky-base" />
<div className="sky-breathe" />
<div className="sun-glow" />
<img src="/backdrops/cloud-3.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "6%", left: "-10%", width: 360, opacity: 0.55, height: "auto" }} />
<img src="/backdrops/cloud-4.webp" alt="" decoding="async" className="cloud-drift cloud-2 absolute select-none" style={{ bottom: "8%", right: "-8%", width: 320, opacity: 0.5, height: "auto" }} />
<img src="/backdrops/cloud-1.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "44%", right: "14%", width: 220, opacity: 0.35, height: "auto" }} />
</div>
<div className="relative z-10 w-full max-w-[560px]">
<a href={WEBSITE_URL} className="mb-5 flex w-fit items-center gap-2.5 mx-auto">
<Logo className="w-7 text-white" />
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
</a>
<motion.div
initial={{ y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="animate-card-float rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04),0_30px_70px_-32px_rgba(15,23,42,0.32)] overflow-hidden"
>
<Steps current={step} />
<div className="px-6 pb-7 pt-2 sm:px-10 sm:pb-9 overflow-hidden">
<AnimatePresence mode="wait" initial={false} custom={dir}>
{step === "code" && (
<motion.div key="code" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<CodeStep code={code} setCode={setCode} loading={complete && lookup.isLoading} error={complete && lookup.isError ? (lookup.error as unknown as AppError) : null} onRetry={reset} />
</motion.div>
)}
{step === "review" && info && (
<motion.div key="review" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<ReviewStep
info={info}
orgs={orgs.data ?? []}
orgsLoading={orgs.isLoading}
orgId={orgId}
setOrgId={setOrgId}
busy={approve.isPending || deny.isPending}
approving={approve.isPending}
onApprove={doApprove}
onDeny={doDeny}
onBack={reset}
/>
</motion.div>
)}
{step === "done" && info && (
<motion.div key="done" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<DoneStep approved={outcome === "approved"} info={info} orgName={orgs.data?.find((o) => o.id === orgId)?.name} onAnother={reset} />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<Link to="/app/emails" className="hover:text-white transition-colors">Back to dashboard</Link>
<span className="text-white/40">·</span>
<a href="https://docs.warmbly.com/api/cli/" target="_blank" rel="noreferrer" className="hover:text-white transition-colors">About the CLI</a>
</div>
</div>
</div>
);
}
const STEPS: { key: "code" | "review" | "done"; label: string }[] = [
{ key: "code", label: "Code" },
{ key: "review", label: "Review" },
{ key: "done", label: "Signed in" },
];
function Steps({ current }: { current: "code" | "review" | "done" }) {
const idx = STEPS.findIndex((s) => s.key === current);
return (
<div className="px-6 sm:px-10 pt-7 pb-5 flex items-center gap-2">
{STEPS.map((s, i) => {
const state = i < idx ? "done" : i === idx ? "current" : "todo";
return (
<React.Fragment key={s.key}>
<div className="flex items-center gap-2">
<motion.span
animate={{
backgroundColor: state === "todo" ? "#f1f5f9" : "#0284c7",
color: state === "todo" ? "#94a3b8" : "#ffffff",
}}
className="size-6 rounded-full inline-flex items-center justify-center text-[11px] font-semibold"
>
{state === "done" ? <CheckIcon className="w-3 h-3" /> : i + 1}
</motion.span>
<span className={`text-[12px] font-medium ${state === "todo" ? "text-slate-400" : "text-slate-900"}`}>{s.label}</span>
</div>
{i < STEPS.length - 1 && (
<span className="relative flex-1 h-px bg-slate-200 overflow-hidden rounded-full">
<motion.span animate={{ width: i < idx ? "100%" : "0%" }} transition={{ duration: 0.4 }} className="absolute inset-y-0 left-0 bg-sky-600" />
</span>
)}
</React.Fragment>
);
})}
</div>
);
}
function CodeStep({ code, setCode, loading, error, onRetry }: { code: string; setCode: (c: string) => void; loading: boolean; error: AppError | null; onRetry: () => void }) {
return (
<div>
<div className="text-center">
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-sky-50 text-sky-700 text-[11px] font-medium">
<TerminalIcon className="w-3 h-3" /> Warmbly CLI
</span>
<h1 className="mt-4 text-[24px] sm:text-[28px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">Sign in to the CLI</h1>
<p className="mt-2.5 text-[13.5px] text-slate-500 leading-relaxed max-w-md mx-auto">
Enter the eight character code your terminal is showing. Approving it creates an API key for that machine, which you can revoke here at any time.
</p>
</div>
<div className="mt-8 flex justify-center">
<InputOTP maxLength={CODE_LENGTH} value={code} onChange={(v) => setCode(clean(v))} pattern={REGEXP_ONLY_DIGITS_AND_CHARS} pasteTransformer={clean} autoFocus containerClassName="gap-1.5 sm:gap-2" disabled={loading}>
<InputOTPGroup className="gap-1.5 sm:gap-2">
{[0, 1, 2, 3].map((i) => (
<Slot key={i} index={i} />
))}
</InputOTPGroup>
<span className="w-3 h-px bg-slate-300 mx-0.5 sm:mx-1" aria-hidden="true" />
<InputOTPGroup className="gap-1.5 sm:gap-2">
{[4, 5, 6, 7].map((i) => (
<Slot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<div className="mt-5 min-h-[44px] flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
{loading && (
<motion.p key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="inline-flex items-center gap-2 text-[12.5px] text-slate-500">
<Loader2Icon className="w-3.5 h-3.5 animate-spin" /> Looking up your terminal
</motion.p>
)}
{error && !loading && (
<motion.div key="error" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="w-full rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-center">
<p className="text-[13px] font-medium text-rose-700">{error.message || "That code is unknown or has expired."}</p>
<p className="mt-0.5 text-[12px] text-rose-600/80">
Run <span className="font-mono">warmbly auth login</span> again for a fresh one, then{" "}
<button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-rose-800">
enter it here
</button>
.
</p>
</motion.div>
)}
{!loading && !error && (
<motion.p key="hint" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-[12px] text-slate-400 text-center">
You can paste the whole code. Codes expire ten minutes after the terminal printed them.
</motion.p>
)}
</AnimatePresence>
</div>
</div>
);
}
function Slot({ index }: { index: number }) {
return (
<InputOTPSlot
index={index}
className="!w-10 !h-12 sm:!w-12 sm:!h-14 !rounded-lg !border !border-slate-200 !shadow-none first:!rounded-lg last:!rounded-lg text-[20px] font-semibold font-mono text-slate-900 data-[active=true]:!border-sky-400 data-[active=true]:!ring-sky-400/15"
/>
);
}
// The scope names the API returns are SCREAMING_SNAKE; the reviewer reads prose.
function scopeLabel(name: string): string {
const words = name.toLowerCase().replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
function ReviewStep({
info,
orgs,
orgsLoading,
orgId,
setOrgId,
busy,
approving,
onApprove,
onDeny,
onBack,
}: {
info: CLIAuthCode;
orgs: Organization[];
orgsLoading: boolean;
orgId: string;
setOrgId: (id: string) => void;
busy: boolean;
approving: boolean;
onApprove: () => void;
onDeny: () => void;
onBack: () => void;
}) {
const pending = info.status === "pending";
const sends = info.scope_names.includes("SEND_CAMPAIGNS") || info.scope_names.includes("WRITE_UNIBOX");
return (
<div>
<button type="button" onClick={onBack} className="inline-flex items-center gap-1 text-[12px] text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeftIcon className="w-3.5 h-3.5" /> Different code
</button>
<h1 className="mt-3 text-[22px] sm:text-[26px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">
{pending ? "Authorize this terminal" : "This code was already used"}
</h1>
<div className="mt-5 flex items-center gap-4 rounded-xl border border-slate-200 bg-gradient-to-b from-sky-50/60 to-white px-4 py-4">
<span className="size-11 rounded-lg bg-slate-900 text-white inline-flex items-center justify-center shrink-0">
<TerminalIcon className="w-5 h-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-slate-900 truncate">{info.client_name || "Warmbly CLI"}</p>
<p className="text-[12px] text-slate-500 truncate inline-flex items-center gap-1.5">
<MonitorIcon className="w-3 h-3 shrink-0" />
{info.hostname || "Machine name not shared"}
{info.cli_version && <span className="text-slate-400">· v{info.cli_version}</span>}
</p>
</div>
<span className="hidden sm:inline-flex font-mono text-[13px] tracking-[0.18em] text-slate-400">{info.user_code}</span>
</div>
{!pending ? (
<div className="mt-5">
<p className="text-[13px] text-slate-500 leading-relaxed">
{info.status === "denied"
? "The request was declined. Run `warmbly auth login` again if you changed your mind."
: "It is signed in already. If the terminal is still waiting, run `warmbly auth login` again for a fresh code."}
</p>
<div className="mt-5 flex items-center gap-2">
<Link to="/app/api-keys" className="h-10 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13px] font-medium inline-flex items-center gap-1.5 transition-colors">
API keys <ArrowRightIcon className="w-3.5 h-3.5" />
</Link>
</div>
</div>
) : (
<>
<div className="mt-6">
<p className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Sign in to workspace</p>
<div className="mt-2 space-y-1.5">
{orgsLoading && (
<div className="h-12 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{orgs.map((o) => {
const on = o.id === orgId;
return (
<button
key={o.id}
type="button"
onClick={() => setOrgId(o.id)}
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
on ? "border-sky-400 bg-sky-50/60 ring-2 ring-sky-100" : "border-slate-200 hover:border-slate-300"
}`}
>
<span className={`size-8 rounded-md inline-flex items-center justify-center shrink-0 overflow-hidden ${on ? "bg-sky-600 text-white" : "bg-slate-100 text-slate-600"}`}>
{o.avatar ? <img src={o.avatar} alt="" className="size-full object-cover" /> : <BuildingIcon className="w-4 h-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium text-slate-900 truncate">{o.name}</span>
<span className="block text-[11.5px] text-slate-500 capitalize">{o.role}</span>
</span>
<span className={`size-4 rounded-full border inline-flex items-center justify-center ${on ? "border-sky-600 bg-sky-600 text-white" : "border-slate-300"}`}>
{on && <CheckIcon className="w-2.5 h-2.5" />}
</span>
</button>
);
})}
{!orgsLoading && orgs.length === 0 && <p className="text-[12.5px] text-slate-500">You are not a member of any workspace yet.</p>}
</div>
</div>
<div className="mt-5">
<p className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
This terminal will be able to
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{info.scope_names.map((s) => (
<li key={s} className="h-6 px-2 rounded-md bg-slate-100 text-slate-700 text-[11.5px] inline-flex items-center">
{scopeLabel(s)}
</li>
))}
{info.scope_names.length === 0 && <li className="text-[12.5px] text-slate-500">Nothing. The CLI asked for no scopes.</li>}
</ul>
</div>
{sends && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12.5px] text-amber-800 leading-relaxed">
These scopes include sending. A CLI signed in with them can start campaigns and send replies, which puts real mail on the wire.
</p>
)}
<ul className="mt-5 grid sm:grid-cols-2 gap-2.5">
<Perm icon={KeyRoundIcon} title="What this creates" body="One API key named for this machine, listed under API keys, revocable there or with `warmbly auth logout`." />
<Perm icon={LockIcon} title="What it is not" body="Not your password and not a session. It only carries the scopes above, in the workspace you pick." />
</ul>
<div className="mt-6 flex items-center gap-2">
<button
type="button"
onClick={onDeny}
disabled={busy}
className="h-10 px-4 rounded-md border border-slate-200 hover:border-slate-300 text-[13px] text-slate-700 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
<XIcon className="w-3.5 h-3.5" /> Decline
</button>
<button
type="button"
onClick={onApprove}
disabled={!orgId || busy}
className="flex-1 h-10 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13.5px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
>
{approving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <CheckIcon className="w-4 h-4" />}
Authorize terminal
</button>
</div>
</>
)}
</div>
);
}
function Perm({ icon: Icon, title, body }: { icon: React.ComponentType<{ className?: string }>; title: string; body: string }) {
return (
<li className="rounded-lg border border-slate-200 px-3 py-2.5 flex items-start gap-2.5">
<Icon className="w-4 h-4 mt-0.5 text-sky-600 shrink-0" />
<span>
<span className="block text-[12px] font-semibold text-slate-900">{title}</span>
<span className="block text-[12px] text-slate-500 leading-relaxed">{body}</span>
</span>
</li>
);
}
function DoneStep({ approved, info, orgName, onAnother }: { approved: boolean; info: CLIAuthCode; orgName?: string; onAnother: () => void }) {
return (
<div className="flex flex-col items-center text-center py-2">
<motion.span
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 380, damping: 20, delay: 0.1 }}
className={`size-16 rounded-full inline-flex items-center justify-center ${approved ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-500"}`}
>
{approved ? <CheckIcon className="w-8 h-8" /> : <XIcon className="w-8 h-8" />}
</motion.span>
<h1 className="mt-5 text-[24px] sm:text-[28px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">
{approved ? "Terminal authorized" : "Request declined"}
</h1>
<p className="mt-2.5 text-[13.5px] text-slate-500 leading-relaxed max-w-sm">
{approved ? (
<>
<span className="font-medium text-slate-700">{info.hostname || "Your terminal"}</span> picks this up on its own within a few seconds
{orgName ? (
<>
{" "}
and is now signed in to <span className="font-medium text-slate-700">{orgName}</span>.
</>
) : (
"."
)}{" "}
You can close this tab.
</>
) : (
"Nothing was created. The terminal will show that the request was declined."
)}
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<Link
to="/app/api-keys"
className="h-10 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
API keys <ArrowRightIcon className="w-3.5 h-3.5" />
</Link>
<a
href="https://docs.warmbly.com/api/cli/"
target="_blank"
rel="noreferrer"
className="h-10 px-4 rounded-md border border-slate-200 hover:border-slate-300 text-slate-800 text-[13.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
CLI docs <ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
</div>
<button type="button" onClick={onAnother} className="mt-5 text-[12px] text-slate-500 hover:text-slate-900 transition-colors">
Authorize another terminal
</button>
</div>
);
}
+1
View File
@@ -66,6 +66,7 @@ const ROUTE_TITLES: Record<string, string> = {
"/app/settings/profile": "Profile",
"/app/settings/warmbly-cloud": "Warmbly Cloud",
"/connect": "Connect",
"/cli": "Authorize CLI",
"/app/settings/notifications": "Notifications",
"/app/settings/security": "Security",
"/app/settings/members": "Members",
@@ -0,0 +1,22 @@
// /auth/cli/* — a signed-in member reviews the code a CLI is showing and
// authorizes it into one of their workspaces, which mints the API key.
import Request from "@/lib/api/client/Request";
import type { CLIAuthCode } from "@/lib/api/models/app/cliauth/CLIAuth";
export async function describeCLIAuthCode(code: string): Promise<CLIAuthCode> {
return await Request<CLIAuthCode>({ method: "GET", url: `/auth/cli/codes/${encodeURIComponent(code)}`, authorization: true });
}
export async function approveCLIAuthCode(code: string, organizationId: string): Promise<CLIAuthCode> {
return await Request<CLIAuthCode>({
method: "POST",
url: `/auth/cli/codes/${encodeURIComponent(code)}/approve`,
data: { organization_id: organizationId },
authorization: true,
});
}
export async function denyCLIAuthCode(code: string): Promise<void> {
await Request<void>({ method: "POST", url: `/auth/cli/codes/${encodeURIComponent(code)}/deny`, authorization: true });
}
@@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { approveCLIAuthCode, denyCLIAuthCode, describeCLIAuthCode } from "@/lib/api/client/app/cliauth/cliAuth";
export const CLI_AUTH_KEY = ["cli-auth"];
export function useCLIAuthCode(code: string) {
return useQuery({
queryKey: [...CLI_AUTH_KEY, "code", code],
queryFn: () => describeCLIAuthCode(code),
enabled: code.length === 9,
retry: false,
staleTime: 0,
});
}
export function useApproveCLIAuthCode() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ code, organizationId }: { code: string; organizationId: string }) => approveCLIAuthCode(code, organizationId),
// The approval mints a key, so the API keys list is stale everywhere.
onSuccess: () => void qc.invalidateQueries({ queryKey: ["api-keys"] }),
});
}
export function useDenyCLIAuthCode() {
return useMutation({ mutationFn: (code: string) => denyCLIAuthCode(code) });
}
@@ -0,0 +1,18 @@
// /auth/cli/* — the browser half of `warmbly auth login`.
export type CLIAuthCodeStatus = "pending" | "approved" | "claimed" | "denied";
export interface CLIAuthCode {
id: string;
user_code: string;
client_name: string;
hostname: string;
cli_version: string;
scopes: number;
scope_names: string[];
status: CLIAuthCodeStatus;
organization_id?: string;
api_key_id?: string;
expires_at: string;
created_at: string;
}
+6
View File
@@ -89,6 +89,7 @@ import OnboardingPage from './app/onboarding/page';
import SelectOrgPage from './app/select-org/page';
import InviteAcceptPage from './app/invite/page';
import ConnectPage from './app/connect/page';
import CLIAuthPage from './app/cli/page';
import CloudOAuthDonePage from './app/cloud-oauth/done/page';
import WarmblyCloudSettingsPage from './app/app/settings/warmbly-cloud/page';
import SetupPage from './app/setup/page';
@@ -209,6 +210,11 @@ const router = createBrowserRouter([
path: "connect",
element: <ConnectPage />,
},
{
// Where `warmbly auth login` sends the browser to approve its code.
path: "cli",
element: <CLIAuthPage />,
},
{
// Where Warmbly Cloud sends the Google/Microsoft popup back to on a linked instance.
path: "cloud-oauth/done",