mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
Merge remote-tracking branch 'origin/main' into feature/mailbox-fair-use-allowance
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, '-') }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+190
@@ -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)"
|
||||
Executable
+211
@@ -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'
|
||||
@@ -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 }
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1 @@
|
||||
bc129c17774a4c45bb08c5d6228beedf199ce384aa3a16cb798ebe5c75e1861b cli.sh
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user