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

This commit is contained in:
Matthew Meszaros
2026-09-04 21:25:53 -07:00
70 changed files with 11208 additions and 18 deletions
+6
View File
@@ -40,6 +40,7 @@ import (
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cipher"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -163,6 +164,7 @@ func main() {
var emailService email.EmailService
var poolLinkService poollink.Service
var cloudLinkService cloudlink.Service
var cliAuthService cliauth.Service
var campaignService campaign.CampaignService
var analyticsService analytics.AnalyticsService
var rateLimitService ratelimit.RateLimitService
@@ -1290,6 +1292,9 @@ func main() {
leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService)
apiKeyService = apikey.NewService(cache, apiKeyRepository)
// `warmbly auth login`: the browser approval mints an ordinary API key
// through the service above, so it has to be built after it.
cliAuthService = cliauth.NewService(repository.NewCLIAuthRepository(primaryDB.Pool), apiKeyService, organizationService, userService, organizationRepository)
crmService = crm.NewService(crmRepository)
teamRepository := repository.NewTeamRepository(primaryDB.Pool)
teamService = team.NewService(teamRepository)
@@ -1931,6 +1936,7 @@ func main() {
PoolLinkService: poolLinkService,
CloudLinkService: cloudLinkService,
CLIAuthService: cliAuthService,
TokenService: tokenService,
PasskeyService: passkeyService,
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
)
// Aliases are plain command lines stored in config.yml. They are expanded
// before cobra sees the arguments, so an alias can carry flags and the user
// can still add more on the end.
func newAliasCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "alias <command>",
Short: "Shortcuts for command lines you type often",
GroupID: groupSetup,
Long: `Save a command line under a shorter name.
Anything after the alias on the command line is appended, so an alias can be a
starting point rather than a fixed command.`,
Example: ` $ warmbly alias set hot "campaign list --status active"
$ warmbly hot --json`,
}
set := &cobra.Command{
Use: "set <name> <expansion>",
Short: "Create or replace an alias",
Args: cobra.ExactArgs(2),
RunE: func(c *cobra.Command, args []string) error {
name, expansion := args[0], args[1]
if strings.ContainsAny(name, " \t") {
return fmt.Errorf("an alias name cannot contain spaces")
}
// Shadowing a real command would make it unreachable, and the
// person who did it would have no way to tell why.
for _, existing := range c.Root().Commands() {
if existing.Name() == name {
return fmt.Errorf("%q is already a warmbly command, so an alias for it would hide it", name)
}
}
if _, err := splitArgs(expansion); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Aliases == nil {
cfg.Aliases = map[string]string{}
}
cfg.Aliases[name] = expansion
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s = %s\n", f.IO.Tick(), name, expansion)
return nil
},
}
list := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "Show every alias",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if len(cfg.Aliases) == 0 {
f.IO.Println(f.IO.Gray("No aliases yet. Try: warmbly alias set hot \"campaign list --status active\""))
return nil
}
names := make([]string, 0, len(cfg.Aliases))
for name := range cfg.Aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
f.IO.Printf("%-12s %s\n", name, cfg.Aliases[name])
}
return nil
},
}
del := &cobra.Command{
Use: "delete <name>",
Aliases: []string{"rm"},
Short: "Remove an alias",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if _, ok := cfg.Aliases[args[0]]; !ok {
return fmt.Errorf("no alias called %q", args[0])
}
delete(cfg.Aliases, args[0])
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s removed %s\n", f.IO.Tick(), args[0])
return nil
},
}
cmd.AddCommand(set, list, del)
return cmd
}
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/output"
)
// newAPICmd is the escape hatch that makes the typed commands optional: every
// endpoint is reachable on day one, whether or not a noun-verb command for it
// exists yet.
func newAPICmd(f *Factory) *cobra.Command {
var (
method string
rawField []string
field []string
headers []string
input string
paginate bool
maxPages int
include bool
silent bool
idemKey string
)
cmd := &cobra.Command{
Use: "api <endpoint>",
Short: "Call any Warmbly API endpoint",
GroupID: groupDevelop,
Long: `Make an authenticated request to the Warmbly REST API.
The endpoint is relative to /v1 unless it already names a version, so
"/campaigns" and "/v1/campaigns" are the same call. The method defaults to GET,
or POST when any field is supplied.
Fields build a JSON body. -f keeps the value a string; -F guesses the type, so
true, false, null and numbers arrive as themselves, and @file or @- reads a
value from a file or stdin. Nested keys use key[sub]=value and repeated key[]
builds an array.`,
Example: ` $ warmbly api /me
$ warmbly api "/campaigns?limit=10" --paginate
$ warmbly api /contacts -f email=jane@example.com -f first_name=Jane
$ warmbly api /campaigns/CAMPAIGN_ID -X PATCH -F daily_limit=40
$ warmbly api /contacts/search -X POST --input filter.json
$ warmbly api /webhooks/WEBHOOK_ID -X DELETE`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
client, err := f.Client()
if err != nil {
return err
}
parsed, err := url.Parse(args[0])
if err != nil {
return fmt.Errorf("%q is not a usable endpoint: %w", args[0], err)
}
body, err := bodyFromArg(f.IO.In, input)
if err != nil {
return err
}
fields, err := buildFields(rawField, field, f.IO.In)
if err != nil {
return err
}
if len(fields) > 0 {
if body != nil {
return fmt.Errorf("pass fields or --input, not both")
}
body, err = json.Marshal(fields)
if err != nil {
return err
}
}
if body != nil && !json.Valid(body) {
return fmt.Errorf("the request body is not valid JSON")
}
if method == "" {
method = http.MethodGet
if body != nil {
method = http.MethodPost
}
}
method = strings.ToUpper(method)
if method == http.MethodGet && body != nil {
return fmt.Errorf("a GET request carries no body. Put parameters in the query string, or pass -X POST.")
}
req := api.Request{
Method: method,
Path: parsed.Path,
Query: parsed.Query(),
Body: body,
IdempotencyKey: idemKey,
Headers: map[string]string{},
}
for _, h := range headers {
name, value, ok := strings.Cut(h, ":")
if !ok {
return fmt.Errorf("headers are name:value, not %q", h)
}
req.Headers[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
if paginate {
if method != http.MethodGet {
return fmt.Errorf("--paginate only makes sense on a GET")
}
merged, perr := client.Paginate(c.Context(), req, maxPages)
if perr != nil {
return perr
}
if silent {
return nil
}
return (&output.Printer{IO: f.IO, JSON: true}).Print(merged, output.Table{})
}
resp, err := client.Do(c.Context(), req)
if resp != nil && include {
f.IO.Printf("HTTP %d\n", resp.Status)
for name, values := range resp.Header {
for _, v := range values {
f.IO.Printf("%s: %s\n", name, v)
}
}
f.IO.Println()
}
if err != nil {
return err
}
if silent {
return nil
}
return (&output.Printer{IO: f.IO, JSON: true, Template: f.Template}).Print(resp.Body, output.Table{})
},
}
cmd.Flags().StringVarP(&method, "method", "X", "", "HTTP method (default GET, or POST when fields are given)")
cmd.Flags().StringArrayVarP(&rawField, "raw-field", "f", nil, "Body field as a string: key=value")
cmd.Flags().StringArrayVarP(&field, "field", "F", nil, "Body field with a guessed type: key=value, key=@file")
cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "Extra request header: name:value")
cmd.Flags().StringVar(&input, "input", "", "Request body: JSON, @file, or - for stdin")
cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow the cursor and merge every page")
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Stop after this many pages")
cmd.Flags().BoolVarP(&include, "include", "i", false, "Print the status and response headers too")
cmd.Flags().BoolVar(&silent, "silent", false, "Do not print the response body")
cmd.Flags().StringVar(&idemKey, "idempotency-key", "", "Idempotency-Key header for a safely retryable write")
return cmd
}
// buildFields turns -f and -F into one JSON object. Order matters only for
// duplicate keys, where the last one wins, as in curl.
func buildFields(raw, typed []string, stdin io.Reader) (map[string]any, error) {
out := map[string]any{}
for _, kv := range raw {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("fields are key=value, not %q", kv)
}
if err := assign(out, key, value); err != nil {
return nil, err
}
}
for _, kv := range typed {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("fields are key=value, not %q", kv)
}
if strings.HasPrefix(value, "@") {
source := value
if value == "@-" {
source = "-"
}
data, err := bodyFromArg(stdin, source)
if err != nil {
return nil, err
}
if err := assign(out, key, strings.TrimRight(string(data), "\n")); err != nil {
return nil, err
}
continue
}
if err := assign(out, key, guessType(value)); err != nil {
return nil, err
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// guessType is the -F conversion: JSON literals become themselves, everything
// else stays a string.
func guessType(v string) any {
switch v {
case "true":
return true
case "false":
return false
case "null":
return nil
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
if fl, err := strconv.ParseFloat(v, 64); err == nil {
return fl
}
return v
}
// assign writes one field, understanding key[sub] for nesting and key[] for
// appending to an array.
func assign(obj map[string]any, key string, value any) error {
open := strings.Index(key, "[")
if open < 0 {
obj[key] = value
return nil
}
if !strings.HasSuffix(key, "]") {
return fmt.Errorf("unbalanced brackets in field %q", key)
}
head := key[:open]
inner := key[open+1 : len(key)-1]
if head == "" {
return fmt.Errorf("field %q has no name", key)
}
if inner == "" {
existing, _ := obj[head].([]any)
obj[head] = append(existing, value)
return nil
}
nested, ok := obj[head].(map[string]any)
if !ok {
nested = map[string]any{}
obj[head] = nested
}
return assign(nested, inner, value)
}
+747
View File
@@ -0,0 +1,747 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/oauth"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
)
func newAuthCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth <command>",
Short: "Sign in, sign out, and see who you are",
GroupID: groupCore,
Long: `Sign the CLI in to a Warmbly instance.
Signing in through the browser creates one API key named for this machine. It
appears under Settings > API keys and can be revoked there or with
` + "`warmbly auth logout`" + `. Credentials are written to ` + config.HostsPath() + `
at 0600, and WARMBLY_TOKEN overrides the file without ever being written to it,
which is how CI authenticates with no login step.`,
}
cmd.AddCommand(
newAuthLoginCmd(f),
newAuthStatusCmd(f),
newAuthTokenCmd(f),
newAuthSwitchCmd(f),
newAuthRefreshCmd(f),
newAuthLogoutCmd(f),
)
return cmd
}
// parseScopes accepts the two presets plus any comma or space separated list
// of scope names, in either case, so `--scopes read_campaigns,READ_CONTACTS`
// works and a typo is named rather than silently dropped.
func parseScopes(raw string) (uint64, error) {
raw = strings.TrimSpace(raw)
switch strings.ToLower(raw) {
case "":
return models.APIPermFullAccess, nil
case "full", "full-access", "all":
return models.APIPermFullAccess, nil
case "read", "read-only", "readonly":
return models.APIPermReadOnly, nil
}
mask, unknown := oauth.ParseScopes(strings.NewReplacer(",", " ", "+", " ").Replace(raw))
if len(unknown) > 0 {
return 0, fmt.Errorf("unknown scope %s.\nRun `warmbly key permissions` for the full list, or use the presets: full, read-only.", strings.Join(unknown, ", "))
}
if mask == 0 {
return 0, fmt.Errorf("--scopes granted nothing. Name at least one scope, or use full or read-only.")
}
return mask, nil
}
func newAuthLoginCmd(f *Factory) *cobra.Command {
var (
hostname string
apiURL string
withToken bool
web bool
scopeStr string
force bool
)
cmd := &cobra.Command{
Use: "login",
Short: "Sign in to a Warmbly instance",
Long: `Sign in to a Warmbly instance.
With no flags this asks which instance, then how: a browser approval that
creates a key for this machine, or pasting a key you already have.`,
Example: ` $ warmbly auth login
$ warmbly auth login --hostname warmbly.acme.com
$ warmbly auth login --scopes read-only
$ echo $KEY | warmbly auth login --with-token`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthLogin(c.Context(), f, hostname, apiURL, withToken, web, scopeStr, force)
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Instance to sign in to (default: warmbly.com)")
cmd.Flags().StringVar(&apiURL, "api-url", "", "API base URL, when it is not derivable from the hostname")
cmd.Flags().BoolVar(&withToken, "with-token", false, "Read an API key from stdin instead of using the browser")
cmd.Flags().BoolVarP(&web, "web", "w", false, "Go straight to the browser approval")
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
cmd.Flags().BoolVar(&force, "force", false, "Replace an existing sign-in for this host without asking")
return cmd
}
func runAuthLogin(ctx context.Context, f *Factory, hostname, apiURL string, withToken, web bool, scopeStr string, force bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
// 1. Which instance.
if hostname == "" && apiURL == "" {
if !io.IsStdinTTY() {
hostname = config.DefaultHost
} else {
idx, serr := io.Select("Where do you want to sign in?", []string{
"warmbly.com (the hosted service)",
"A self-hosted instance",
})
if serr != nil {
return serr
}
if idx == 0 {
hostname = config.DefaultHost
} else {
answer, ierr := io.Input("Instance hostname (for example warmbly.acme.com)", "")
if ierr != nil {
return ierr
}
if strings.TrimSpace(answer) == "" {
return fmt.Errorf("a hostname is required to sign in to a self-hosted instance")
}
hostname = answer
}
}
}
if hostname == "" {
hostname = apiURL
}
host := config.NormalizeHost(hostname)
if existing := hosts[host]; existing != nil && !force {
if !io.IsStdinTTY() {
return fmt.Errorf("already signed in to %s as %s. Pass --force to replace that sign-in.", host, existing.User)
}
ok, cerr := io.Confirm(fmt.Sprintf("Already signed in to %s as %s. Sign in again?", host, existing.User), false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
// 2. Where its API is.
base, err := resolveAPIBase(ctx, f, host, apiURL)
if err != nil {
return err
}
// 3. How to authenticate.
useToken := withToken
if !withToken && !web && io.IsStdinTTY() {
idx, serr := io.Select("How do you want to sign in?", []string{
"Approve in a browser (creates a key for this machine)",
"Paste an API key you already have",
})
if serr != nil {
return serr
}
useToken = idx == 1
}
scopes, err := parseScopes(scopeStr)
if err != nil {
return err
}
var entry *config.Host
if useToken {
entry, err = loginWithToken(ctx, f, base)
} else {
entry, err = loginWithBrowser(ctx, f, cfg, base, scopes)
}
if err != nil {
return err
}
hosts[host] = entry
if err := hosts.Save(); err != nil {
return err
}
// Signing in makes that host the active one. Nobody expects to sign in and
// still have the next command talk to somewhere else.
if cfg.ActiveHost != host {
cfg.ActiveHost = host
if err := cfg.Save(); err != nil {
return err
}
}
io.Errorf("%s Signed in to %s as %s\n", io.Tick(), io.Bold(host), io.Bold(entry.User))
if entry.Organization != "" {
io.Errorf(" Workspace %s\n", entry.Organization)
}
io.Errorf(" Credentials written to %s\n", config.HostsPath())
return nil
}
// resolveAPIBase finds the API for a host, probing the layouts the installer
// writes. Guessing wrong here produces a confusing 404 on every later command,
// so it is settled once, at sign-in, and stored.
func resolveAPIBase(ctx context.Context, f *Factory, host, explicit string) (string, error) {
if explicit != "" {
return strings.TrimRight(explicit, "/"), nil
}
if v := strings.TrimSpace(os.Getenv(config.APIURLEnv)); v != "" {
return strings.TrimRight(v, "/"), nil
}
if host == config.DefaultHost {
return config.DefaultAPIURL(host), nil
}
candidates := config.CandidateAPIURLs(host)
for _, base := range candidates {
if reachable(ctx, f, base) {
if f.Debug {
f.IO.Errorf("* resolved API base %s\n", base)
}
return base, nil
}
}
return "", fmt.Errorf("could not find a Warmbly API for %s. Tried:\n %s\nPass --api-url with the instance's API base URL (the API_PUBLIC_URL it is configured with).", host, strings.Join(candidates, "\n "))
}
// deploymentConfig is the slice of GET /auth/config the CLI uses: enough to
// tell a Warmbly API from any other 200, plus the two URLs a client cannot
// derive on a self-hosted instance.
type deploymentConfig struct {
Registration string `json:"registration"`
AppURL string `json:"app_url"`
WebsocketURL string `json:"websocket_url"`
}
// fetchDeploymentConfig reads the public deployment facts, or nil when the
// base is not a Warmbly API. /health alone would accept any 200.
func fetchDeploymentConfig(ctx context.Context, f *Factory, base string) *deploymentConfig {
client := api.New(base, "", UserAgent())
client.HTTP.Timeout = 8 * time.Second
if f.Debug {
client.Debug = f.IO.ErrOut
}
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
resp, err := client.Do(probeCtx, api.Request{Method: http.MethodGet, Path: "/auth/config", Anonymous: true})
if err != nil || resp == nil || resp.Status != http.StatusOK {
return nil
}
var probe deploymentConfig
if json.Unmarshal(resp.Body, &probe) != nil || probe.Registration == "" {
return nil
}
return &probe
}
func reachable(ctx context.Context, f *Factory, base string) bool {
return fetchDeploymentConfig(ctx, f, base) != nil
}
func loginWithToken(ctx context.Context, f *Factory, base string) (*config.Host, error) {
io := f.IO
if io.IsStdinTTY() {
io.Errorln(io.Gray("Create a key under Settings > API keys, then paste it here. It is not echoed."))
}
token, err := io.Secret("API key")
if err != nil {
return nil, err
}
token = strings.TrimSpace(token)
if token == "" {
return nil, fmt.Errorf("no key was given")
}
if !strings.HasPrefix(token, apikey.KeyPrefix) {
return nil, fmt.Errorf("that does not look like a Warmbly API key: it should start with %q", apikey.KeyPrefix)
}
entry := &config.Host{APIURL: base, Token: token, AddedAt: time.Now().UTC()}
if err := fillIdentity(ctx, f, entry); err != nil {
return nil, err
}
if cfg := fetchDeploymentConfig(ctx, f, base); cfg != nil {
entry.AppURL = cfg.AppURL
}
return entry, nil
}
func loginWithBrowser(ctx context.Context, f *Factory, cfg *config.Config, base string, scopes uint64) (*config.Host, error) {
io := f.IO
client := api.New(base, "", UserAgent())
if f.Debug {
client.Debug = io.ErrOut
}
machine, _ := os.Hostname()
start, err := startDeviceFlow(ctx, client, machine, scopes)
if err != nil {
return nil, err
}
target := start.VerificationURLComplete
if target == "" {
target = start.VerificationURL
}
io.Errorf("\n %s %s\n", io.Gray("Your code:"), io.Bold(start.UserCode))
io.Errorf(" %s %s\n\n", io.Gray("Approve at:"), target)
if io.IsStdinTTY() {
if ok, cerr := io.Confirm("Open that in your browser now?", true); cerr == nil && ok {
if berr := openBrowser(cfg, target); berr != nil {
io.Errorln(io.Gray("Could not open a browser. Use the link above."))
}
}
}
io.Errorf("%s Waiting for approval (the code expires in %d minutes)\n", io.Gray("…"), start.ExpiresIn/60)
result, err := pollDeviceFlow(ctx, client, start)
if err != nil {
return nil, err
}
entry := &config.Host{
APIURL: base,
AppURL: appURLFromVerification(start.VerificationURL),
Token: result.Token,
User: result.UserEmail,
UserID: result.UserID,
Organization: result.OrganizationName,
OrganizationID: result.OrganizationID,
Scopes: result.ScopeNames,
APIKeyID: result.APIKeyID,
AddedAt: time.Now().UTC(),
}
if entry.User == "" {
// The approval did not carry an identity; ask the API who we are.
if err := fillIdentity(ctx, f, entry); err != nil {
return nil, err
}
}
return entry, nil
}
// appURLFromVerification recovers the dashboard origin from the approval link
// the instance just handed us, which is the instance's own APP_URL and so is
// exact where a hostname guess is not.
func appURLFromVerification(raw string) string {
return strings.TrimSuffix(strings.TrimSpace(raw), "/cli")
}
// fillIdentity calls /me, which both validates the credential and gives the
// host entry the labels `auth status` prints.
func fillIdentity(ctx context.Context, f *Factory, entry *config.Host) error {
client := api.New(entry.APIURL, entry.Token, UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
var id models.Identity
if err := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/me"}, &id); err != nil {
if api.StatusOf(err) == http.StatusUnauthorized {
return fmt.Errorf("that key was rejected by %s. Check it is a key for this instance and has not been revoked.", entry.APIURL)
}
return err
}
entry.User = id.Email
entry.UserID = id.UserID.String()
entry.Scopes = id.Scopes
entry.Organization = id.OrganizationName
if id.OrganizationID != nil {
entry.OrganizationID = id.OrganizationID.String()
}
return nil
}
func newAuthStatusCmd(f *Factory) *cobra.Command {
var showToken bool
cmd := &cobra.Command{
Use: "status",
Short: "Show which hosts you are signed in to",
Long: `Show every signed-in host, who you are on it, and where the credential
came from. Run this first when a command fails with a credential error: an
environment variable nobody remembers exporting is the usual answer.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthStatus(c.Context(), f, showToken)
},
}
cmd.Flags().BoolVarP(&showToken, "show-token", "t", false, "Print the token itself")
return cmd
}
func runAuthStatus(ctx context.Context, f *Factory, showToken bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
resolved, resolveErr := f.Resolved()
names := hosts.Names()
// A token from the environment points at a host that may not be in the
// file at all; it still deserves a line.
if resolveErr == nil && hosts[resolved.Host] == nil {
names = append(names, resolved.Host)
}
if len(names) == 0 {
io.Errorf("%s Not signed in anywhere.\n", io.Cross())
io.Errorln(io.Gray("Run `warmbly auth login` to sign in."))
return errSilent
}
sort.Strings(names)
failed := false
for _, name := range names {
active := resolveErr == nil && name == resolved.Host
marker := " "
if active {
marker = io.Green("* ")
}
io.Printf("%s%s\n", marker, io.Bold(name))
entry := hosts[name]
token := ""
source := config.HostsPath()
base := ""
if entry != nil {
token, base = entry.Token, entry.APIURL
}
if active {
token, source, base = resolved.Token, resolved.Source, resolved.APIURL
}
if token == "" {
io.Printf(" %s no credential\n", io.Cross())
failed = true
continue
}
probe := &config.Host{APIURL: base, Token: token}
if err := fillIdentity(ctx, f, probe); err != nil {
io.Printf(" %s %s\n", io.Cross(), err.Error())
failed = true
} else {
io.Printf(" %s signed in as %s\n", io.Tick(), io.Bold(probe.User))
if probe.Organization != "" {
io.Printf(" - workspace: %s\n", probe.Organization)
}
io.Printf(" - scopes: %s\n", scopeSummary(probe.Scopes))
}
io.Printf(" - api: %s\n", base)
io.Printf(" - token from: %s\n", source)
if showToken {
io.Printf(" - token: %s\n", token)
} else {
io.Printf(" - token: %s\n", maskToken(token))
}
}
if cfg.ActiveHost != "" && len(names) > 1 {
io.Println()
io.Println(io.Gray("The * host is the one commands use. `warmbly auth switch` changes it."))
}
if failed {
return errSilent
}
return nil
}
// scopeSummary keeps a full-access key from printing twenty-four lines.
func scopeSummary(scopes []string) string {
if len(scopes) == 0 {
return "none reported"
}
if len(scopes) >= len(models.AllAPIPermissions) {
return fmt.Sprintf("all %d", len(scopes))
}
if len(scopes) > 6 {
return fmt.Sprintf("%s and %d more", strings.Join(scopes[:6], ", "), len(scopes)-6)
}
return strings.Join(scopes, ", ")
}
func maskToken(t string) string {
if len(t) <= 12 {
return strings.Repeat("*", len(t))
}
return t[:8] + strings.Repeat("*", 8) + t[len(t)-4:]
}
func newAuthTokenCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Print the token the CLI is using",
Long: `Print the active token on stdout and nothing else, so it can be piped
into another tool or exported into a CI environment.`,
Example: ` $ export WARMBLY_TOKEN=$(warmbly auth token)
$ curl -H "Authorization: Bearer $(warmbly auth token)" https://api.warmbly.com/v1/me`,
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
r, err := f.Resolved()
if err != nil {
return err
}
f.IO.Println(r.Token)
return nil
},
}
}
func newAuthSwitchCmd(f *Factory) *cobra.Command {
var hostname string
cmd := &cobra.Command{
Use: "switch",
Short: "Change which signed-in host commands use",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 1 {
hostname = args[0]
}
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
names := hosts.Names()
if len(names) == 0 {
return fmt.Errorf("not signed in anywhere. Run `warmbly auth login` first.")
}
if hostname == "" {
if len(names) == 1 {
hostname = names[0]
} else {
labels := make([]string, len(names))
for i, n := range names {
labels[i] = n
if hosts[n].User != "" {
labels[i] += " " + f.IO.Gray(hosts[n].User)
}
}
idx, serr := f.IO.Select("Use which host?", labels)
if serr != nil {
return serr
}
hostname = names[idx]
}
}
host := config.NormalizeHost(hostname)
if hosts[host] == nil {
return fmt.Errorf("not signed in to %s. Signed in to: %s", host, strings.Join(names, ", "))
}
cfg.ActiveHost = host
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s Now using %s\n", f.IO.Tick(), f.IO.Bold(host))
return nil
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to switch to")
return cmd
}
func newAuthRefreshCmd(f *Factory) *cobra.Command {
var scopeStr string
cmd := &cobra.Command{
Use: "refresh",
Short: "Sign in again, usually to add scopes",
Long: `Run the browser sign-in again for the active host.
A key's scopes are fixed when it is created, so widening what the CLI may do
means a new key. The old one is revoked once the new one works.`,
Example: ` $ warmbly auth refresh --scopes full
$ warmbly auth refresh --scopes read_campaigns,send_campaigns`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
r, err := f.Resolved()
if err != nil {
return err
}
old := r.Entry
if err := runAuthLogin(c.Context(), f, r.Host, r.APIURL, false, true, scopeStr, true); err != nil {
return err
}
// Revoke the key we replaced, so refreshing does not accumulate a
// key per run under Settings > API keys.
if old != nil && old.APIKeyID != "" {
if err := revokeKey(c.Context(), f, old.APIKeyID); err != nil && f.Debug {
f.IO.Errorf("* could not revoke the previous key: %v\n", err)
}
}
return nil
},
}
cmd.Flags().StringVarP(&scopeStr, "scopes", "s", "", "Scopes to request: full, read-only, or a list of names")
return cmd
}
func newAuthLogoutCmd(f *Factory) *cobra.Command {
var hostname string
var keepKey bool
cmd := &cobra.Command{
Use: "logout",
Short: "Sign out and revoke this machine's key",
Long: `Forget a host's credential.
The key the sign-in created is revoked on the instance too, so signing out on
a machine you are handing back actually ends its access. --keep-key skips the
revocation, for a key you pasted in and use elsewhere.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runAuthLogout(c.Context(), f, hostname, keepKey)
},
}
cmd.Flags().StringVar(&hostname, "hostname", "", "Host to sign out of (default: the active one)")
cmd.Flags().BoolVar(&keepKey, "keep-key", false, "Forget the credential locally without revoking it")
return cmd
}
func runAuthLogout(ctx context.Context, f *Factory, hostname string, keepKey bool) error {
io := f.IO
cfg, err := f.Config()
if err != nil {
return err
}
hosts, err := f.Hosts()
if err != nil {
return err
}
host := config.NormalizeHost(hostname)
if hostname == "" {
r, rerr := f.Resolved()
if rerr != nil {
return rerr
}
host = r.Host
}
entry := hosts[host]
if entry == nil {
return fmt.Errorf("not signed in to %s", host)
}
if !f.AssumeYes && io.IsStdinTTY() {
ok, cerr := io.Confirm(fmt.Sprintf("Sign out of %s as %s?", host, entry.User), false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
// Revoke first: if it fails, the credential is still in the file and the
// user can retry, which is better than a live key nobody can reach.
revoked := false
if !keepKey && entry.APIKeyID != "" {
f.hosts = hosts
if err := revokeKeyWith(ctx, f, entry, entry.APIKeyID); err != nil {
io.Errorf("%s Could not revoke the key on %s: %v\n", io.Yellow("!"), host, err)
io.Errorln(io.Gray("Revoke it by hand under Settings > API keys."))
} else {
revoked = true
}
}
delete(hosts, host)
if err := hosts.Save(); err != nil {
return err
}
if cfg.ActiveHost == host {
cfg.ActiveHost = ""
if names := hosts.Names(); len(names) == 1 {
cfg.ActiveHost = names[0]
}
if err := cfg.Save(); err != nil {
return err
}
}
io.Errorf("%s Signed out of %s\n", io.Tick(), io.Bold(host))
if revoked {
io.Errorln(io.Gray("The key this machine was using has been revoked."))
}
return nil
}
func revokeKey(ctx context.Context, f *Factory, keyID string) error {
r, err := f.Resolved()
if err != nil {
return err
}
return revokeKeyWith(ctx, f, r.Entry, keyID)
}
// revokeKeyWith ends a key using the credential in entry.
//
// It asks the instance to revoke the calling credential first: self-revocation
// needs no scope, so it works for a read-only sign-in, where deleting by id
// would be refused. The by-id path is the fallback for an instance that
// predates /api-keys/self, and for revoking a key that is not the caller.
func revokeKeyWith(ctx context.Context, f *Factory, entry *config.Host, keyID string) error {
if entry == nil {
return fmt.Errorf("no credential to revoke with")
}
client := api.New(entry.APIURL, entry.Token, UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
if keyID == "" || keyID == entry.APIKeyID {
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/self"})
if err == nil {
return nil
}
if status := api.StatusOf(err); status != http.StatusNotFound && status != http.StatusMethodNotAllowed {
return err
}
if keyID == "" {
return err
}
}
_, err := client.Do(ctx, api.Request{Method: http.MethodDelete, Path: "/api-keys/" + keyID})
if err != nil && api.StatusOf(err) == http.StatusNotFound {
// Already gone is the outcome we wanted.
return nil
}
return err
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
)
// browseTargets maps a noun to its dashboard path, so `warmbly browse
// campaigns` does not require anyone to remember the URL layout.
var browseTargets = map[string]string{
"campaigns": "/app/campaigns",
"campaign": "/app/campaigns",
"contacts": "/app/contacts",
"contact": "/app/contacts",
"mailboxes": "/app/emails",
"mailbox": "/app/emails",
"emails": "/app/emails",
"inbox": "/app/unibox",
"unibox": "/app/unibox",
"analytics": "/app/analytics",
"automations": "/app/automations",
"forms": "/app/forms",
"templates": "/app/templates",
"crm": "/app/crm",
"audit": "/app/audit",
"keys": "/app/api-keys",
"api-keys": "/app/api-keys",
"settings": "/app/settings/profile",
"webhooks": "/app/settings/webhooks",
"billing": "/app/settings/billing",
"members": "/app/settings/members",
"deliverability": "/app/deliverability",
}
// browseDetail is the subset that has a per-record page, for `warmbly browse
// campaign <id>`.
var browseDetail = map[string]string{
"campaign": "/app/campaigns",
"campaigns": "/app/campaigns",
"contact": "/app/contacts",
"contacts": "/app/contacts",
"mailbox": "/app/emails",
"mailboxes": "/app/emails",
"automation": "/app/automations",
"form": "/app/forms",
"forms": "/app/forms",
}
func newBrowseCmd(f *Factory) *cobra.Command {
var printOnly bool
cmd := &cobra.Command{
Use: "browse [<section>] [<id>]",
Short: "Open the dashboard in a browser",
GroupID: groupCore,
Long: `Open the Warmbly dashboard for the host you are signed in to.
With a section it goes straight there; with a section and an id it opens that
record. --no-browser prints the URL instead, which is what you want over SSH.`,
Example: ` $ warmbly browse
$ warmbly browse campaigns
$ warmbly browse campaign 6f1c...
$ warmbly browse inbox --no-browser`,
Args: cobra.MaximumNArgs(2),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
r, err := f.Resolved()
if err != nil {
return err
}
path := "/app/emails"
if len(args) > 0 {
section := strings.ToLower(args[0])
if len(args) > 1 {
detail, ok := browseDetail[section]
if !ok {
return fmt.Errorf("%q has no detail page to open by id. Sections with one: %s", args[0], keysOf(browseDetail))
}
path = detail + "/" + args[1]
} else {
target, ok := browseTargets[section]
if !ok {
return fmt.Errorf("nothing to browse called %q. Try one of: %s", args[0], keysOf(browseTargets))
}
path = target
}
}
url := dashboardURL(r) + path
if printOnly || !f.IO.IsStdoutTTY() {
f.IO.Println(url)
return nil
}
f.IO.Errorf("%s Opening %s\n", f.IO.Gray("→"), url)
if err := openBrowser(cfg, url); err != nil {
f.IO.Println(url)
}
return nil
},
}
cmd.Flags().BoolVar(&printOnly, "no-browser", false, "Print the URL instead of opening it")
return cmd
}
// dashboardURL is where this host's dashboard lives. The instance reports its
// own APP_URL at sign-in, which is exact; the derivation below is the fallback
// for a credential that came from the environment and never signed in.
func dashboardURL(r *config.Resolved) string {
if r.Entry != nil && strings.TrimSpace(r.Entry.AppURL) != "" {
return strings.TrimRight(r.Entry.AppURL, "/")
}
return appBaseURL(r.Host)
}
// appBaseURL is the dashboard for a host, following the layout the installer
// writes: app.<host> for a real deployment, the host itself for a local one.
func appBaseURL(host string) string {
host = config.NormalizeHost(host)
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
return "http://" + host
}
if strings.Contains(host, ":") {
return "http://" + host
}
return "https://app." + host
}
// keysOf lists a target map's names, sorted, for an error message.
func keysOf(m map[string]string) string {
out := make([]string, 0, len(m))
for name := range m {
out = append(out, name)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
+301
View File
@@ -0,0 +1,301 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
)
func TestFillPath(t *testing.T) {
specs := []argSpec{{Name: "id"}, {Name: "step"}}
got, err := fillPath("/campaigns/{id}/steps/{step}", specs, []string{"abc", "def"})
if err != nil {
t.Fatalf("fill: %v", err)
}
if got != "/campaigns/abc/steps/def" {
t.Errorf("path = %q", got)
}
if _, err := fillPath("/campaigns/{id}", specs[:1], []string{" "}); err == nil {
t.Error("an empty argument must be rejected rather than producing /campaigns/")
}
// A tool name can contain characters that need escaping in a path.
got, err = fillPath("/ai/tools/{name}/call", []argSpec{{Name: "name"}}, []string{"a b"})
if err != nil {
t.Fatalf("fill: %v", err)
}
if !strings.Contains(got, "a%20b") {
t.Errorf("path segment was not escaped: %q", got)
}
}
// Every spec has to produce a runnable command: one {} per positional argument
// and no leftovers, or the command is dead on arrival at runtime.
func TestEverySpecPathMatchesItsArguments(t *testing.T) {
seen := map[string]bool{}
for _, r := range resourceSpecs() {
if seen[r.Name] {
t.Errorf("two resources are called %q", r.Name)
}
seen[r.Name] = true
endpoints := map[string]bool{}
for _, e := range r.Endpoints {
if endpoints[e.Name] {
t.Errorf("%s has two %q commands", r.Name, e.Name)
}
endpoints[e.Name] = true
if e.Method == "" || e.Path == "" || e.Short == "" {
t.Errorf("%s %s is missing a method, path or summary", r.Name, e.Name)
}
if !strings.HasPrefix(e.Path, "/") {
t.Errorf("%s %s path %q must be /v1-relative", r.Name, e.Name, e.Path)
}
placeholders := strings.Count(e.Path, "{")
if placeholders != len(e.Args) {
t.Errorf("%s %s has %d placeholders and %d arguments", r.Name, e.Name, placeholders, len(e.Args))
}
args := make([]string, len(e.Args))
for i := range e.Args {
args[i] = "x"
if e.Args[i].Help == "" {
t.Errorf("%s %s argument %q has no help", r.Name, e.Name, e.Args[i].Name)
}
}
if _, err := fillPath(e.Path, e.Args, args); err != nil {
t.Errorf("%s %s: %v", r.Name, e.Name, err)
}
if e.Method == http.MethodGet && e.Body != bodyNone {
t.Errorf("%s %s is a GET with a body", r.Name, e.Name)
}
flags := map[string]bool{}
for _, fl := range e.Flag {
if flags[fl.Name] {
t.Errorf("%s %s declares --%s twice", r.Name, e.Name, fl.Name)
}
flags[fl.Name] = true
if fl.Help == "" {
t.Errorf("%s %s flag --%s has no help", r.Name, e.Name, fl.Name)
}
// -h is cobra's help shorthand; claiming it breaks the command.
if fl.Short == "h" {
t.Errorf("%s %s cannot use -h for --%s", r.Name, e.Name, fl.Name)
}
}
}
}
}
// Building the whole command tree catches the failures cobra reports by
// panicking: a duplicate shorthand, a bad group id.
func TestCommandTreeBuilds(t *testing.T) {
f := NewFactory()
root := newRootCmd(f)
if len(root.Commands()) == 0 {
t.Fatal("no commands registered")
}
for _, c := range root.Commands() {
if c.GroupID == "" && c.Name() != "help" && c.Name() != "completion" {
t.Errorf("%s has no group, so it falls out of the grouped help", c.Name())
}
for _, sub := range c.Commands() {
if sub.Short == "" {
t.Errorf("%s %s has no summary", c.Name(), sub.Name())
}
}
}
}
func TestBuildFields(t *testing.T) {
body, err := buildFields(
[]string{"name=Jane", "note=true"},
[]string{"limit=40", "active=true", "missing=null", "tags[]=a", "tags[]=b", "nested[key]=v"},
strings.NewReader(""),
)
if err != nil {
t.Fatalf("build: %v", err)
}
if body["name"] != "Jane" {
t.Errorf("-f name should stay a string, got %#v", body["name"])
}
if body["note"] != "true" {
t.Errorf("-f keeps values literal, got %#v", body["note"])
}
if body["limit"] != int64(40) {
t.Errorf("-F limit should be a number, got %#v", body["limit"])
}
if body["active"] != true {
t.Errorf("-F active should be a bool, got %#v", body["active"])
}
if body["missing"] != nil {
t.Errorf("-F null should be null, got %#v", body["missing"])
}
tags, _ := body["tags"].([]any)
if len(tags) != 2 {
t.Errorf("key[] should build an array, got %#v", body["tags"])
}
nested, _ := body["nested"].(map[string]any)
if nested["key"] != "v" {
t.Errorf("key[sub] should nest, got %#v", body["nested"])
}
if _, err := buildFields([]string{"broken"}, nil, strings.NewReader("")); err == nil {
t.Error("a field with no = must be rejected")
}
}
func TestSplitArgs(t *testing.T) {
got, err := splitArgs(`campaign list --status "in progress" --q 'x y'`)
if err != nil {
t.Fatalf("split: %v", err)
}
want := []string{"campaign", "list", "--status", "in progress", "--q", "x y"}
if len(got) != len(want) {
t.Fatalf("got %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %#v, want %#v", got, want)
}
}
if _, err := splitArgs(`unbalanced "`); err == nil {
t.Error("an unbalanced quote must be an error, not a silent truncation")
}
// An empty quoted argument is still an argument.
if got, _ := splitArgs(`a "" b`); len(got) != 3 {
t.Errorf("empty quoted argument was dropped: %#v", got)
}
}
func TestExpandAliases(t *testing.T) {
dir := t.TempDir()
t.Setenv(config.DirEnv, dir)
cfg := &config.Config{Aliases: map[string]string{"hot": "campaign list --status active"}}
if err := cfg.Save(); err != nil {
t.Fatalf("save: %v", err)
}
f := NewFactory()
got := expandAliases(f, []string{"hot", "--json"})
want := []string{"campaign", "list", "--status", "active", "--json"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("expanded to %v, want %v", got, want)
}
// A non-alias is untouched.
if got := expandAliases(f, []string{"campaign", "list"}); got[0] != "campaign" {
t.Errorf("a real command was rewritten: %v", got)
}
}
func TestParseScopes(t *testing.T) {
if mask, err := parseScopes(""); err != nil || mask != models.APIPermFullAccess {
t.Errorf("empty should mean full access, got %d %v", mask, err)
}
if mask, err := parseScopes("read-only"); err != nil || mask != models.APIPermReadOnly {
t.Errorf("read-only preset = %d %v", mask, err)
}
mask, err := parseScopes("read_campaigns,SEND_CAMPAIGNS")
if err != nil {
t.Fatalf("named scopes: %v", err)
}
if mask&models.APIPermReadCampaigns == 0 || mask&models.APIPermSendCampaigns == 0 {
t.Errorf("named scopes did not resolve: %d", mask)
}
if _, err := parseScopes("not_a_scope"); err == nil {
t.Error("an unknown scope must be named, not dropped")
}
}
// The device flow is the whole sign-in, so it gets an end-to-end test against
// a server that behaves like the real one: pending, then approved once.
func TestDeviceFlow(t *testing.T) {
polls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/auth/cli/code":
var req models.CLIAuthStartRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.CLIVersion == "" || req.Scopes == 0 {
t.Errorf("the CLI must identify itself and name its scopes: %+v", req)
}
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"device_code":"dc","user_code":"ABCD-EFGH","verification_uri":"https://app.example/cli","verification_uri_complete":"https://app.example/cli?code=ABCD-EFGH","expires_in":600,"interval":1}`)
case "/v1/auth/cli/poll":
polls++
if polls < 2 {
fmt.Fprint(w, `{"status":"pending"}`)
return
}
fmt.Fprint(w, `{"status":"approved","token":"wmbly_minted","user_email":"jane@example.com","organization_name":"Acme","api_key_id":"key-1","scope_names":["READ_CAMPAIGNS"]}`)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
client := api.New(srv.URL, "", "test")
start, err := startDeviceFlow(context.Background(), client, "laptop", models.APIPermReadOnly)
if err != nil {
t.Fatalf("start: %v", err)
}
if start.UserCode != "ABCD-EFGH" {
t.Errorf("user code = %q", start.UserCode)
}
result, err := pollDeviceFlow(context.Background(), client, start)
if err != nil {
t.Fatalf("poll: %v", err)
}
if polls < 2 {
t.Errorf("the client stopped polling before approval")
}
if result.Token != "wmbly_minted" || result.UserEmail != "jane@example.com" {
t.Errorf("approval payload lost: %+v", result)
}
}
func TestDeviceFlowStopsOnDenial(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"denied"}`)
}))
defer srv.Close()
client := api.New(srv.URL, "", "test")
_, err := pollDeviceFlow(context.Background(), client, &deviceStart{DeviceCode: "dc", UserCode: "X", Interval: 1, ExpiresIn: 60})
if err == nil || !strings.Contains(err.Error(), "declined") {
t.Errorf("a denial must end the wait, got %v", err)
}
}
func TestAppURLFromVerification(t *testing.T) {
// The instance hands back its own APP_URL with /cli on the end, which is
// exact where a hostname guess is not.
if got := appURLFromVerification("https://app.acme.dev/cli"); got != "https://app.acme.dev" {
t.Errorf("got %q", got)
}
if got := appURLFromVerification("http://localhost:5173/cli"); got != "http://localhost:5173" {
t.Errorf("got %q", got)
}
if got := appURLFromVerification(""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestDashboardURLPrefersWhatTheInstanceReported(t *testing.T) {
r := &config.Resolved{Host: "acme.dev", Entry: &config.Host{AppURL: "https://warmbly.acme.dev/"}}
if got := dashboardURL(r); got != "https://warmbly.acme.dev" {
t.Errorf("got %q, want the reported origin", got)
}
// With nothing reported, fall back to the layout the installer writes.
if got := dashboardURL(&config.Resolved{Host: "acme.dev"}); got != "https://app.acme.dev" {
t.Errorf("fallback = %q", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
)
func newConfigCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config <command>",
Short: "Read and write the CLI's own settings",
GroupID: groupSetup,
Long: `Read and write ` + config.ConfigPath() + `.
These are preferences, not credentials: credentials live in hosts.yml and are
managed with ` + "`warmbly auth`" + `.`,
}
get := &cobra.Command{
Use: "get <key>",
Short: "Print one setting",
Args: cobra.ExactArgs(1),
RunE: func(*cobra.Command, []string) error { return nil },
}
get.RunE = func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if !knownKey(args[0]) {
return unknownKey(args[0])
}
f.IO.Println(cfg.Get(args[0]))
return nil
}
set := &cobra.Command{
Use: "set <key> <value>",
Short: "Change one setting",
Example: " $ warmbly config set output json\n $ warmbly config set confirm always",
Args: cobra.ExactArgs(2),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if err := cfg.Set(args[0], args[1]); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s = %s\n", f.IO.Tick(), args[0], args[1])
return nil
},
}
list := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "Show every setting and what it does",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
for _, k := range config.Keys {
value := cfg.Get(k.Name)
if value == "" {
value = f.IO.Gray("(unset, " + k.Default + ")")
}
f.IO.Printf("%-14s %s\n", k.Name, value)
f.IO.Printf("%-14s %s\n", "", f.IO.Gray(k.Help))
}
f.IO.Println()
f.IO.Printf("%s %s\n", f.IO.Gray("config:"), config.ConfigPath())
f.IO.Printf("%s %s\n", f.IO.Gray("hosts: "), config.HostsPath())
return nil
},
}
clear := &cobra.Command{
Use: "clear <key>",
Short: "Reset one setting to its default",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
if !knownKey(args[0]) {
return unknownKey(args[0])
}
// Set validates values, so clearing goes through the zero value
// directly rather than through a value it would reject.
switch args[0] {
case "active_host":
cfg.ActiveHost = ""
case "output":
cfg.Output = ""
case "confirm":
cfg.Confirm = ""
case "pager":
cfg.Pager = ""
case "browser":
cfg.Browser = ""
}
if err := cfg.Save(); err != nil {
return err
}
f.IO.Errorf("%s %s reset\n", f.IO.Tick(), args[0])
return nil
},
}
cmd.AddCommand(get, set, list, clear)
return cmd
}
func knownKey(name string) bool {
for _, k := range config.Keys {
if k.Name == name {
return true
}
}
return false
}
func unknownKey(name string) error {
names := make([]string, 0, len(config.Keys))
for _, k := range config.Keys {
names = append(names, k.Name)
}
sort.Strings(names)
return fmt.Errorf("unknown config key %q. Settable keys: %s", name, strings.Join(names, ", "))
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"runtime"
"strings"
"time"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/version"
)
// The browser half of `warmbly auth login`, RFC 8628 shaped: ask for a code,
// show it, wait for a member to approve it in the browser, collect the key.
type deviceStart struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_uri"`
VerificationURLComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type devicePoll struct {
Status string `json:"status"`
Token string `json:"token"`
APIKeyID string `json:"api_key_id"`
ScopeNames []string `json:"scope_names"`
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
}
// startDeviceFlow opens the handshake. The client is anonymous: there is
// nothing to authenticate with yet.
func startDeviceFlow(ctx context.Context, client *api.Client, hostname string, scopes uint64) (*deviceStart, error) {
body, err := json.Marshal(models.CLIAuthStartRequest{
ClientName: "Warmbly CLI",
Hostname: hostname,
CLIVersion: version.String(),
Scopes: scopes,
})
if err != nil {
return nil, err
}
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/code", Body: body, Anonymous: true})
if err != nil {
if api.StatusOf(err) == http.StatusNotFound || api.StatusOf(err) == http.StatusNotImplemented {
return nil, fmt.Errorf("%s does not support browser sign-in for the CLI.\nUse `warmbly auth login --with-token` with an API key from Settings > API keys instead.", client.BaseURL)
}
return nil, err
}
var out deviceStart
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, fmt.Errorf("the sign-in handshake returned something unexpected: %w", err)
}
if out.DeviceCode == "" || out.UserCode == "" {
return nil, errors.New("the sign-in handshake returned no code")
}
if out.Interval <= 0 {
out.Interval = 3
}
if out.ExpiresIn <= 0 {
out.ExpiresIn = 600
}
return &out, nil
}
// pollDeviceFlow waits for the browser half. It stops on approval, denial,
// expiry, or the context being cancelled, and never faster than the interval
// the server asked for.
func pollDeviceFlow(ctx context.Context, client *api.Client, start *deviceStart) (*devicePoll, error) {
body, err := json.Marshal(map[string]string{"device_code": start.DeviceCode})
if err != nil {
return nil, err
}
interval := time.Duration(start.Interval) * time.Second
deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("the code %s expired before it was approved. Run `warmbly auth login` again.", start.UserCode)
}
resp, err := client.Do(ctx, api.Request{Method: http.MethodPost, Path: "/auth/cli/poll", Body: body, Anonymous: true})
if err != nil {
// The code is gone: expired, or someone else claimed it.
if api.StatusOf(err) == http.StatusNotFound {
return nil, fmt.Errorf("the code %s is no longer valid. Run `warmbly auth login` again.", start.UserCode)
}
// A rate limit or a blip must not end a sign-in someone is
// standing at; back off and keep waiting.
if api.StatusOf(err) == http.StatusTooManyRequests || api.StatusOf(err) == 0 {
interval += time.Second
continue
}
return nil, err
}
var out devicePoll
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, err
}
switch out.Status {
case string(models.CLIAuthCodeApproved):
if out.Token == "" {
return nil, errors.New("the approval returned no token. Run `warmbly auth login` again.")
}
return &out, nil
case string(models.CLIAuthCodeDenied):
return nil, errors.New("the request was declined in the browser. Nothing was created.")
case string(models.CLIAuthCodeClaimed):
return nil, errors.New("that code was already used. Run `warmbly auth login` again.")
}
}
}
// openBrowser opens a URL, honouring the browser config key and then BROWSER.
// A failure is never fatal: the URL has already been printed.
func openBrowser(cfg *config.Config, url string) error {
if custom := strings.TrimSpace(cfg.Browser); custom != "" {
parts, err := splitArgs(custom)
if err != nil || len(parts) == 0 {
return fmt.Errorf("the browser config key is not a usable command")
}
return exec.Command(parts[0], append(parts[1:], url)...).Start()
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
// `warmbly events tail` is the terminal view of the developer WebSocket: the
// same stream the dashboard runs on, printed as it happens. It is the fastest
// way to see whether an integration is receiving what you think it is, without
// standing up a public webhook URL first.
//
// The socket speaks the Phoenix channel protocol, serializer 1.0.0: every
// frame is [join_ref, ref, topic, event, payload].
func newEventsCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "events <command>",
Short: "Watch live events as they happen",
GroupID: groupDevelop,
Long: `Subscribe to the workspace's live event stream.
This needs a key with the REALTIME_SUBSCRIBE scope. Sign in again with
` + "`warmbly auth refresh --scopes full`" + ` if the key you have does not carry it.`,
}
cmd.AddCommand(newEventsTailCmd(f))
return cmd
}
func newEventsTailCmd(f *Factory) *cobra.Command {
var (
intents []string
wsURL string
orgID string
compact bool
maxCount int
)
cmd := &cobra.Command{
Use: "tail",
Short: "Stream live events to the terminal",
Long: `Print events as Warmbly publishes them: sends, opens, clicks, replies,
inbox arrivals, campaign state, and the custom events your automations fire.
Filter with --intent, which matches the event type as a case-insensitive
substring, so --intent EMAIL takes EMAIL_SENT, EMAIL_OPENED and EMAIL_RECEIVED.
Intents reduce traffic; they are not a permission boundary, and the key's
scopes still decide what reaches you at all.`,
Example: ` $ warmbly events tail
$ warmbly events tail --intent EMAIL --intent CAMPAIGN
$ warmbly events tail --json | jq 'select(.event_type == "EMAIL_REPLIED")'`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runEventsTail(c.Context(), f, wsURL, orgID, intents, compact, maxCount)
},
}
cmd.Flags().StringArrayVar(&intents, "intent", nil, "Only these event families, for example EMAIL or CAMPAIGN")
cmd.Flags().StringVar(&wsURL, "url", "", "WebSocket URL, when the instance does not advertise one")
cmd.Flags().StringVar(&orgID, "org", "", "Organization to subscribe to (default: the signed-in one)")
cmd.Flags().BoolVar(&compact, "compact", false, "One line per event, even on a terminal")
cmd.Flags().IntVar(&maxCount, "count", 0, "Stop after this many events")
return cmd
}
func runEventsTail(ctx context.Context, f *Factory, wsURL, orgID string, intents []string, compact bool, maxCount int) error {
io := f.IO
r, err := f.Resolved()
if err != nil {
return err
}
if orgID == "" && r.Entry != nil {
orgID = r.Entry.OrganizationID
}
if orgID == "" {
// The key knows its own organization even when the config file does not.
client, cerr := f.Client()
if cerr != nil {
return cerr
}
var id struct {
OrganizationID string `json:"organization_id"`
}
if jerr := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/me"}, &id); jerr != nil {
return jerr
}
orgID = id.OrganizationID
}
if orgID == "" {
return fmt.Errorf("this credential is not scoped to a workspace, so there is no org channel to join")
}
endpoint, err := resolveSocketURL(ctx, f, r, wsURL)
if err != nil {
return err
}
target := endpoint
if !strings.Contains(target, "?") {
target += "?"
} else {
target += "&"
}
target += "vsn=1.0.0&token=" + url.QueryEscape(r.Token)
if f.Debug {
io.Errorf("* connecting to %s\n", endpoint)
}
dialer := websocket.Dialer{HandshakeTimeout: 20 * time.Second}
conn, resp, err := dialer.DialContext(ctx, target, nil)
if resp != nil && resp.Body != nil {
// The handshake response body carries the rejection reason and nothing
// the stream needs; the socket itself is what stays open.
defer resp.Body.Close()
}
if err != nil {
if resp != nil && resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("the realtime gateway refused this key.\nIt needs the REALTIME_SUBSCRIBE scope: `warmbly auth refresh --scopes full`.")
}
return fmt.Errorf("could not connect to the realtime gateway at %s: %w\nPass --url if this instance serves it somewhere else.", endpoint, err)
}
defer conn.Close()
topic := "org:" + orgID
payload := map[string]any{}
if len(intents) > 0 {
payload["intents"] = intents
}
if err := conn.WriteJSON([]any{"1", "1", topic, "phx_join", payload}); err != nil {
return err
}
io.Errorf("%s Listening on %s%s\n", io.Gray("…"), io.Bold(topic), intentSuffix(io, intents))
io.Errorln(io.Gray("Ctrl-C to stop."))
// Heartbeats are client-initiated. The join reply carries the cadence the
// server wants; until it arrives, the documented default is safe.
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
done := make(chan error, 1)
go func() {
seen := 0
for {
var frame []json.RawMessage
if err := conn.ReadJSON(&frame); err != nil {
done <- err
return
}
if len(frame) < 5 {
continue
}
var event string
var body json.RawMessage
_ = json.Unmarshal(frame[3], &event)
body = frame[4]
switch event {
case "phx_reply":
if hb := handleJoinReply(io, body, heartbeat); hb != nil {
done <- hb
return
}
continue
case "phx_close", "phx_error":
done <- fmt.Errorf("the channel closed. Rejoin with `warmbly events tail`.")
return
case "phx_join", "heartbeat":
continue
}
printEvent(f, event, body, compact)
seen++
if maxCount > 0 && seen >= maxCount {
done <- nil
return
}
}
}()
ref := 2
for {
select {
case <-ctx.Done():
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return nil
case err := <-done:
if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
return err
}
return nil
case <-heartbeat.C:
ref++
if err := conn.WriteJSON([]any{nil, fmt.Sprint(ref), "phoenix", "heartbeat", map[string]any{}}); err != nil {
return err
}
}
}
}
// handleJoinReply reads the HELLO the org channel answers a join with: it
// carries the heartbeat cadence, so the client does not hardcode one, and it
// is where a rejected join is reported.
func handleJoinReply(io *iostreams.IOStreams, body json.RawMessage, heartbeat *time.Ticker) error {
var reply struct {
Status string `json:"status"`
Response struct {
Role string `json:"role"`
HeartbeatIntervalMS int `json:"heartbeat_interval_ms"`
Seq int64 `json:"seq"`
Reason string `json:"reason"`
} `json:"response"`
}
if err := json.Unmarshal(body, &reply); err != nil {
return nil
}
if reply.Status == "error" {
reason := reply.Response.Reason
if reason == "" {
reason = "the gateway refused the join"
}
return fmt.Errorf("could not join the channel: %s", reason)
}
if reply.Response.HeartbeatIntervalMS > 1000 {
heartbeat.Reset(time.Duration(reply.Response.HeartbeatIntervalMS) * time.Millisecond)
}
if reply.Response.Role != "" {
io.Errorf("%s\n", io.Gray("Joined as "+reply.Response.Role+"."))
}
return nil
}
func printEvent(f *Factory, event string, body json.RawMessage, compact bool) {
io := f.IO
if f.JSONOut || !io.IsStdoutTTY() {
io.Println(strings.TrimSpace(string(body)))
return
}
var fields map[string]any
_ = json.Unmarshal(body, &fields)
stamp := time.Now().Format("15:04:05")
io.Printf("%s %s %s\n", io.Gray(stamp), eventColour(io, event), io.Gray(summarize(fields)))
if compact {
return
}
// The interesting ids, on one indented line, so a terminal stays readable
// while still carrying enough to look something up.
var parts []string
for _, key := range []string{"campaign_id", "contact_id", "email_id", "thread_id", "email_account_id", "name", "entity_type", "action"} {
if v, ok := fields[key]; ok && v != nil && fmt.Sprint(v) != "" {
parts = append(parts, fmt.Sprintf("%s=%v", key, v))
}
}
if len(parts) > 0 {
io.Printf(" %s\n", io.Gray(strings.Join(parts, " ")))
}
}
func eventColour(io *iostreams.IOStreams, event string) string {
switch {
case strings.Contains(event, "FAILED"), strings.Contains(event, "BOUNCE"), strings.Contains(event, "ERROR"):
return io.Red(event)
case strings.Contains(event, "REPLIED"), strings.Contains(event, "BOOKED"):
return io.Green(event)
case strings.Contains(event, "OPENED"), strings.Contains(event, "CLICKED"):
return io.Cyan(event)
default:
return io.Bold(event)
}
}
// summarize is the short human tail of an event line: whichever descriptive
// field the event happens to carry.
func summarize(fields map[string]any) string {
for _, key := range []string{"subject", "email", "to", "contact_email", "campaign_name", "message", "status", "name"} {
if v, ok := fields[key]; ok {
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" && s != "<nil>" {
if len(s) > 70 {
s = s[:69] + "…"
}
return s
}
}
}
return ""
}
// intentSuffix names the filter in the "listening" line, so a stream that goes
// quiet does not look broken when it is only filtered.
func intentSuffix(io *iostreams.IOStreams, intents []string) string {
if len(intents) == 0 {
return ""
}
return io.Gray(" (" + strings.Join(intents, ", ") + " only)")
}
// resolveSocketURL finds the realtime gateway: the flag, then what the
// instance advertises on /auth/config, then the layouts the installer writes.
func resolveSocketURL(ctx context.Context, f *Factory, r *config.Resolved, explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
client := api.New(r.APIURL, "", UserAgent())
if f.Debug {
client.Debug = f.IO.ErrOut
}
var cfg struct {
WebsocketURL string `json:"websocket_url"`
}
if err := client.JSON(ctx, api.Request{Method: http.MethodGet, Path: "/auth/config", Anonymous: true}, &cfg); err == nil && cfg.WebsocketURL != "" {
return cfg.WebsocketURL, nil
}
host := config.NormalizeHost(r.Host)
if host == config.DefaultHost {
return "wss://realtime." + config.DefaultHost + "/socket/websocket", nil
}
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
return "ws://" + strings.Split(host, ":")[0] + ":4000/socket/websocket", nil
}
// The installer's proxy and Caddy layouts both put it on ws.<host>.
return "wss://ws." + host + "/socket/websocket", nil
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"fmt"
"io"
"os"
"runtime"
"strings"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
"github.com/warmbly/warmbly/internal/cli/output"
"github.com/warmbly/warmbly/internal/version"
)
// Factory is what every command is handed: the terminal, the two config files,
// and a way to build an authenticated client. Building it lazily matters,
// because `warmbly auth login` and `warmbly version` must work before there is
// anything to authenticate with.
type Factory struct {
IO *iostreams.IOStreams
// Global flags, bound once on the root command.
HostFlag string
JSONOut bool
Template string
Fields []string
AssumeYes bool
NoColor bool
Debug bool
cfg *config.Config
hosts config.Hosts
}
func NewFactory() *Factory {
return &Factory{IO: iostreams.System()}
}
// UserAgent identifies the CLI in API usage logs, which is how an operator
// tells a CLI call from a script's.
func UserAgent() string {
return fmt.Sprintf("warmbly-cli/%s (%s/%s)", version.String(), runtime.GOOS, runtime.GOARCH)
}
func (f *Factory) Config() (*config.Config, error) {
if f.cfg != nil {
return f.cfg, nil
}
cfg, err := config.Load()
if err != nil {
return nil, err
}
f.cfg = cfg
return cfg, nil
}
func (f *Factory) Hosts() (config.Hosts, error) {
if f.hosts != nil {
return f.hosts, nil
}
hosts, err := config.LoadHosts()
if err != nil {
return nil, err
}
f.hosts = hosts
return hosts, nil
}
// Resolved answers which host and token this invocation uses.
func (f *Factory) Resolved() (*config.Resolved, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
hosts, err := f.Hosts()
if err != nil {
return nil, err
}
return config.Resolve(cfg, hosts, f.HostFlag)
}
// Client builds an authenticated client, or explains how to get one.
func (f *Factory) Client() (*api.Client, error) {
r, err := f.Resolved()
if err != nil {
return nil, err
}
c := api.New(r.APIURL, r.Token, UserAgent())
if f.Debug {
c.Debug = f.IO.ErrOut
}
return c, nil
}
// Printer is the renderer for this invocation, honouring the config default
// and then the flags.
func (f *Factory) Printer() *output.Printer {
jsonOut := f.JSONOut
if !jsonOut {
if cfg, err := f.Config(); err == nil && cfg.Get("output") == "json" {
jsonOut = true
}
}
return &output.Printer{IO: f.IO, JSON: jsonOut, Template: f.Template, Fields: f.Fields}
}
// ConfirmSend is the gate in front of anything that puts real mail on the
// wire. Without a terminal it refuses rather than sending, because a script
// that forgot --yes must not discover the omission by mailing strangers.
func (f *Factory) ConfirmSend(what string) error {
if f.AssumeYes {
return nil
}
if !f.IO.IsStdinTTY() {
return fmt.Errorf("%s sends real mail, and there is no terminal to confirm on.\nPass --yes to go ahead.", what)
}
ok, err := f.IO.Confirm(f.IO.Yellow("! ")+what+" sends real mail. Continue?", false)
if err != nil {
return err
}
if !ok {
return errCancelled
}
return nil
}
// ConfirmMutation guards a destructive but non-sending change. It only asks
// when the user opted in with `warmbly config set confirm always`, because
// prompting on every write makes a CLI tiring to use.
func (f *Factory) ConfirmMutation(what string) error {
if f.AssumeYes {
return nil
}
cfg, err := f.Config()
if err != nil || cfg.Get("confirm") != "always" {
return nil
}
if !f.IO.IsStdinTTY() {
return nil
}
ok, cerr := f.IO.Confirm(what+"?", false)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
return nil
}
// bodyFromArg turns a --input value into a request body: a JSON literal, `-`
// for stdin, or @path for a file, the conventions curl taught everyone.
func bodyFromArg(in io.Reader, data string) ([]byte, error) {
data = strings.TrimSpace(data)
if data == "" {
return nil, nil
}
switch {
case data == "-":
return io.ReadAll(in)
case strings.HasPrefix(data, "@"):
return os.ReadFile(strings.TrimPrefix(data, "@"))
default:
return []byte(data), nil
}
}
+101
View File
@@ -0,0 +1,101 @@
// warmbly is the command line interface to Warmbly.
//
// It is the customer's CLI, not the operator's: it signs in as a person, holds
// one credential per host in ~/.config/warmbly, and speaks only the public
// REST API, so it drives the hosted service and any self-hosted instance the
// caller can reach. Everything it can do is bounded by the scopes the sign-in
// approved, and it never serves HTTP.
//
// The other CLI, warmblyctl, is the operator's: it talks to Postgres directly,
// runs inside the backend container, and exists for recovery and accounts. If
// you are asking "what is wrong with this install", that is the one you want.
//
// warmbly auth login
// warmbly campaign list
// warmbly api "/campaigns?limit=10"
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/iostreams"
)
// errCancelled is a user saying no at a prompt. It exits 1 with no error line,
// because the person already knows what happened.
var errCancelled = errors.New("cancelled")
// errSilent lets a command print its own failure and still exit non-zero.
var errSilent = errors.New("")
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
f := NewFactory()
root := newRootCmd(f)
root.SetArgs(expandAliases(f, os.Args[1:]))
err := root.ExecuteContext(ctx)
// After the command, never before: the reminder is not worth delaying a
// result for, and it must not appear instead of an error.
if err == nil {
nudgeAboutUpdates(ctx, f)
}
if err != nil {
os.Exit(reportError(f.IO, err))
}
}
// reportError turns whatever came back into one line a person can act on, and
// the exit code a script can branch on:
//
// 1 the command failed
// 2 usage was wrong
// 4 not signed in, or the credential was rejected
func reportError(io *iostreams.IOStreams, err error) int {
if errors.Is(err, errCancelled) {
fmt.Fprintln(io.ErrOut, io.Gray("Cancelled."))
return 1
}
if errors.Is(err, errSilent) {
return 1
}
var noToken *config.ErrNoToken
if errors.As(err, &noToken) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), noToken.Error())
return 4
}
var apiErr *api.Error
if errors.As(err, &apiErr) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), apiErr.Error())
switch {
case apiErr.IsUnauthorized():
fmt.Fprintln(io.ErrOut, io.Gray("The credential was rejected. Run `warmbly auth status` to see which one was used, or `warmbly auth login` to replace it."))
return 4
case apiErr.Status == 403:
fmt.Fprintln(io.ErrOut, io.Gray("The key is missing a scope for this call. `warmbly auth refresh` re-runs the sign-in and can ask for more."))
return 4
}
return 1
}
var noTTY *iostreams.ErrNoTTY
if errors.As(err, &noTTY) {
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), noTTY.Error())
return 2
}
fmt.Fprintf(io.ErrOut, "%s %s\n", io.Cross(), err.Error())
return 1
}
+338
View File
@@ -0,0 +1,338 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/output"
)
// The typed commands are a table. Dispatch, flags, help, the request and the
// rendering all come from one row, so covering a new endpoint is adding a row
// rather than writing a command. `warmbly api` covers anything the table does
// not, which is what keeps the table from having to be exhaustive to be useful.
type bodyMode int
const (
bodyNone bodyMode = iota // the endpoint takes no body
bodyOptional // a body may be sent
bodyRequired // a body must be sent
)
type flagKind int
const (
flagString flagKind = iota
flagInt
flagBool
flagStrings
)
// argSpec is one positional argument, filling the next {} in the path.
type argSpec struct {
Name string
Help string
}
// flagSpec is one flag. Query flags become query parameters; the rest become
// body fields, so `campaign create --name X` needs no JSON.
type flagSpec struct {
Name string
Short string
Help string
Kind flagKind
Query bool
// Key overrides the body field or query parameter name, which otherwise
// is the flag name with dashes turned into underscores.
Key string
}
func (f flagSpec) key() string {
if f.Key != "" {
return f.Key
}
return strings.ReplaceAll(f.Name, "-", "_")
}
type endpoint struct {
Name string
Aliases []string
Short string
Long string
Example string
Method string
// Path is /v1-relative and carries one {name} per positional argument.
Path string
Args []argSpec
Flag []flagSpec
Body bodyMode
// Sends marks a command that puts real mail on the wire. Those confirm.
Sends bool
// Paginate offers --all, which walks the cursor.
Paginate bool
// Idempotent offers --idempotency-key.
Idempotent bool
Table output.Table
// Success is what to say on a terminal when there is nothing to tabulate.
Success string
}
type resource struct {
Name string
Aliases []string
Short string
Long string
Group string
Endpoints []endpoint
}
func resourceCommands(f *Factory) []*cobra.Command {
specs := resourceSpecs()
out := make([]*cobra.Command, 0, len(specs))
for _, r := range specs {
out = append(out, buildResource(f, r))
}
return out
}
func buildResource(f *Factory, r resource) *cobra.Command {
cmd := &cobra.Command{
Use: r.Name + " <command>",
Aliases: r.Aliases,
Short: r.Short,
Long: r.Long,
GroupID: r.Group,
}
for _, e := range r.Endpoints {
cmd.AddCommand(buildEndpoint(f, r, e))
}
return cmd
}
func buildEndpoint(f *Factory, r resource, e endpoint) *cobra.Command {
use := e.Name
for _, a := range e.Args {
use += " <" + a.Name + ">"
}
long := e.Long
if long == "" {
long = e.Short + "."
}
if len(e.Args) > 0 {
var lines []string
for _, a := range e.Args {
lines = append(lines, fmt.Sprintf(" <%s> %s", a.Name, a.Help))
}
long += "\n\nArguments:\n" + strings.Join(lines, "\n")
}
if e.Sends {
long += "\n\nThis command sends real mail. It asks before doing so; --yes skips the question."
}
cmd := &cobra.Command{
Use: use,
Aliases: e.Aliases,
Short: e.Short,
Long: long,
Example: e.Example,
Args: cobra.ExactArgs(len(e.Args)),
}
// Flag values are held here so the runner reads whatever cobra parsed.
strs := map[string]*string{}
ints := map[string]*int{}
bools := map[string]*bool{}
slices := map[string]*[]string{}
for _, fl := range e.Flag {
switch fl.Kind {
case flagInt:
ints[fl.Name] = cmd.Flags().IntP(fl.Name, fl.Short, 0, fl.Help)
case flagBool:
bools[fl.Name] = cmd.Flags().BoolP(fl.Name, fl.Short, false, fl.Help)
case flagStrings:
slices[fl.Name] = cmd.Flags().StringSliceP(fl.Name, fl.Short, nil, fl.Help)
default:
strs[fl.Name] = cmd.Flags().StringP(fl.Name, fl.Short, "", fl.Help)
}
}
var (
input string
rawField []string
typField []string
all bool
maxPages int
idemKey string
)
if e.Body != bodyNone {
cmd.Flags().StringVar(&input, "input", "", "Request body: JSON, @file, or - for stdin")
cmd.Flags().StringArrayVarP(&rawField, "raw-field", "f", nil, "Body field as a string: key=value")
cmd.Flags().StringArrayVarP(&typField, "field", "F", nil, "Body field with a guessed type: key=value")
}
if e.Paginate {
cmd.Flags().BoolVar(&all, "all", false, "Fetch every page, not just the first")
cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Stop after this many pages when --all is set")
}
if e.Idempotent {
cmd.Flags().StringVar(&idemKey, "idempotency-key", "", "Idempotency-Key header for a safely retryable write")
}
cmd.RunE = func(c *cobra.Command, args []string) error {
path, err := fillPath(e.Path, e.Args, args)
if err != nil {
return err
}
query := url.Values{}
body := map[string]any{}
for _, fl := range e.Flag {
if !c.Flags().Changed(fl.Name) {
continue
}
var value any
switch fl.Kind {
case flagInt:
value = *ints[fl.Name]
case flagBool:
value = *bools[fl.Name]
case flagStrings:
value = *slices[fl.Name]
default:
value = *strs[fl.Name]
}
if fl.Query {
query.Set(fl.key(), queryString(value))
continue
}
if err := assign(body, fl.key(), value); err != nil {
return err
}
}
raw, err := bodyFromArg(f.IO.In, input)
if err != nil {
return err
}
fields, err := buildFields(rawField, typField, f.IO.In)
if err != nil {
return err
}
for k, v := range fields {
body[k] = v
}
var payload []byte
switch {
case raw != nil && len(body) > 0:
// Merging a literal body with flags would silently pick a winner.
return fmt.Errorf("pass --input or the field flags, not both")
case raw != nil:
if !json.Valid(raw) {
return fmt.Errorf("the request body is not valid JSON")
}
payload = raw
case len(body) > 0:
payload, err = json.Marshal(body)
if err != nil {
return err
}
case e.Body == bodyRequired:
return fmt.Errorf("%s %s needs a body.\nSupply one with the flags above, with -f key=value, or with --input @file.json", r.Name, e.Name)
case e.Body == bodyOptional && e.Method != http.MethodGet:
payload = []byte("{}")
}
if e.Sends {
if err := f.ConfirmSend(fmt.Sprintf("`warmbly %s %s`", r.Name, e.Name)); err != nil {
return err
}
} else if e.Method == http.MethodDelete {
if err := f.ConfirmMutation(fmt.Sprintf("Run `warmbly %s %s`", r.Name, e.Name)); err != nil {
return err
}
}
client, err := f.Client()
if err != nil {
return err
}
req := api.Request{
Method: e.Method,
Path: path,
Query: query,
Body: payload,
IdempotencyKey: idemKey,
}
printer := f.Printer()
if all {
merged, perr := client.Paginate(c.Context(), req, maxPages)
if perr != nil {
return perr
}
return printer.Print(merged, e.Table)
}
resp, err := client.Do(c.Context(), req)
if err != nil {
return err
}
// Nothing to tabulate and a terminal to talk to: say what happened
// rather than printing an empty object.
if len(e.Table.Columns) == 0 && e.Success != "" && !printer.JSON && printer.Template == "" && f.IO.IsStdoutTTY() {
f.IO.Printf("%s %s\n", f.IO.Tick(), e.Success)
return nil
}
return printer.Print(resp.Body, e.Table)
}
return cmd
}
// fillPath substitutes positional arguments into the {} markers, in order.
func fillPath(path string, specs []argSpec, args []string) (string, error) {
for i, spec := range specs {
marker := "{" + spec.Name + "}"
if !strings.Contains(path, marker) {
return "", fmt.Errorf("internal: path %q has no %s", path, marker)
}
value := strings.TrimSpace(args[i])
if value == "" {
return "", fmt.Errorf("<%s> cannot be empty", spec.Name)
}
path = strings.ReplaceAll(path, marker, url.PathEscape(value))
}
if strings.Contains(path, "{") {
return "", fmt.Errorf("internal: path %q still has an unfilled placeholder", path)
}
return path, nil
}
func queryString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
case int:
return fmt.Sprint(t)
case []string:
return strings.Join(t, ",")
default:
return fmt.Sprint(t)
}
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// Command groups, so `warmbly --help` reads as a product rather than an
// alphabetical dump of forty nouns.
const (
groupCore = "core"
groupWork = "work"
groupData = "data"
groupDevelop = "develop"
groupSetup = "setup"
)
func newRootCmd(f *Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "warmbly <command> <subcommand> [flags]",
Short: "Warmbly from the command line",
Long: `Work with Warmbly from your terminal.
Sign in once with ` + "`warmbly auth login`" + `, then drive campaigns, contacts,
mailboxes and the inbox as yourself. Everything the CLI can do is bounded by
the scopes you approved, on the hosted service or on your own instance.`,
Example: ` $ warmbly auth login
$ warmbly campaign list
$ warmbly mailbox list --json
$ warmbly inbox list --unseen
$ warmbly api "/campaigns?limit=10"`,
SilenceUsage: true,
SilenceErrors: true,
// A bare `warmbly` is a request for the help, not an error.
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 0 {
return c.Help()
}
return fmt.Errorf("unknown command %q. Run `warmbly --help` for the full list.", args[0])
},
}
p := cmd.PersistentFlags()
p.StringVar(&f.HostFlag, "host", "", "Signed-in host to use (default: the active one)")
p.BoolVar(&f.JSONOut, "json", false, "Print the API response as JSON")
p.StringVar(&f.Template, "template", "", "Format the response with a Go template")
p.StringSliceVar(&f.Fields, "fields", nil, "Table columns to keep, comma separated")
p.BoolVar(&f.AssumeYes, "yes", false, "Answer every prompt with yes, including sends")
p.BoolVar(&f.NoColor, "no-color", false, "Never colourise output")
p.BoolVar(&f.Debug, "debug", false, "Print each request to stderr")
cmd.PersistentPreRun = func(*cobra.Command, []string) {
if f.NoColor {
f.IO.SetColor(false)
}
}
cmd.AddGroup(
&cobra.Group{ID: groupCore, Title: "Getting started"},
&cobra.Group{ID: groupWork, Title: "Doing the work"},
&cobra.Group{ID: groupData, Title: "Your data"},
&cobra.Group{ID: groupDevelop, Title: "Building on Warmbly"},
&cobra.Group{ID: groupSetup, Title: "Setting up the CLI"},
)
cmd.AddCommand(newAuthCmd(f))
cmd.AddCommand(newStatusCmd(f))
cmd.AddCommand(newBrowseCmd(f))
cmd.AddCommand(newAPICmd(f))
cmd.AddCommand(newEventsCmd(f))
cmd.AddCommand(newConfigCmd(f))
cmd.AddCommand(newAliasCmd(f))
cmd.AddCommand(newVersionCmd(f))
cmd.AddCommand(newUpgradeCmd(f))
for _, rc := range resourceCommands(f) {
cmd.AddCommand(rc)
}
cmd.SetOut(f.IO.Out)
cmd.SetErr(f.IO.ErrOut)
// The generated completion command has its own group so it does not sit
// under "Getting started" pretending to be a first step.
cmd.SetHelpCommandGroupID(groupSetup)
cmd.SetCompletionCommandGroupID(groupSetup)
return cmd
}
// expandAliases rewrites the argument list when the first word is a user
// alias. Aliases are plain command lines, so `warmbly alias set hot 'campaign
// list --status active'` makes `warmbly hot --json` work.
func expandAliases(f *Factory, args []string) []string {
if len(args) == 0 || strings.HasPrefix(args[0], "-") {
return args
}
cfg, err := f.Config()
if err != nil || len(cfg.Aliases) == 0 {
return args
}
expansion, ok := cfg.Aliases[args[0]]
if !ok {
return args
}
parts, err := splitArgs(expansion)
if err != nil || len(parts) == 0 {
return args
}
return append(parts, args[1:]...)
}
// splitArgs is shell-ish word splitting: quotes group, nothing else is
// special. An alias is a command line, not a shell script.
func splitArgs(in string) ([]string, error) {
var out []string
var cur strings.Builder
var quote rune
started := false
for _, r := range in {
switch {
case quote != 0:
if r == quote {
quote = 0
continue
}
cur.WriteRune(r)
case r == '\'' || r == '"':
quote = r
started = true
case r == ' ' || r == '\t':
if started || cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteRune(r)
}
}
if quote != 0 {
return nil, fmt.Errorf("unbalanced quote in %q", in)
}
if started || cur.Len() > 0 {
out = append(out, cur.String())
}
return out, nil
}
+1925
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/api"
"github.com/warmbly/warmbly/internal/cli/iostreams"
"github.com/warmbly/warmbly/internal/models"
)
// `warmbly status` is the one screen answer to "what is happening in my
// workspace right now". It is several calls composed into one view, and every
// section degrades on its own: a key without analytics scope still gets the
// mailbox and inbox lines rather than one failure for the whole command.
func newStatusCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "What is happening in your workspace right now",
GroupID: groupCore,
Long: `A single screen: who you are, which mailboxes need attention, what is
sending today, and what is waiting for a reply.
Sections you have no scope for are skipped rather than failing the command.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runStatus(c.Context(), f)
},
}
}
func runStatus(ctx context.Context, f *Factory) error {
io := f.IO
client, err := f.Client()
if err != nil {
return err
}
// --json is one document rather than a rendered screen, so a script gets
// the same information without parsing prose.
bundle := map[string]any{}
collect := func(key, path string, query url.Values) json.RawMessage {
resp, derr := client.Do(ctx, api.Request{Method: http.MethodGet, Path: path, Query: query})
if derr != nil {
if f.Debug {
io.Errorf("* %s: %v\n", path, derr)
}
return nil
}
var parsed any
if json.Unmarshal(resp.Body, &parsed) == nil {
bundle[key] = parsed
}
return resp.Body
}
identity := collect("me", "/me", nil)
mailboxes := collect("mailboxes", "/emails", url.Values{"limit": []string{"100"}})
campaigns := collect("campaigns", "/campaigns", url.Values{"limit": []string{"100"}})
unread := collect("inbox", "/unibox/count", nil)
dashboard := collect("analytics", "/analytics/dashboard", nil)
if f.JSONOut || !io.IsStdoutTTY() {
raw, merr := json.MarshalIndent(bundle, "", " ")
if merr != nil {
return merr
}
io.Println(string(raw))
return nil
}
printIdentity(io, identity)
printMailboxes(io, mailboxes)
printCampaigns(io, campaigns)
printInbox(io, unread, dashboard)
if len(bundle) == 0 {
return fmt.Errorf("nothing could be read with this credential. Run `warmbly auth status` to check it.")
}
return nil
}
func printIdentity(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var id struct {
Email string `json:"email"`
OrganizationName string `json:"organization_name"`
}
if json.Unmarshal(raw, &id) != nil {
return
}
line := io.Bold(id.Email)
if id.OrganizationName != "" {
line += io.Gray(" in ") + io.Bold(id.OrganizationName)
}
io.Printf("%s\n\n", line)
}
func printMailboxes(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var list struct {
Data []struct {
Email string `json:"email"`
Status string `json:"status"`
AuthState string `json:"auth_state"`
CampaignLimit int `json:"campaign_limit"`
Warmup *string `json:"warmup"`
} `json:"data"`
}
if json.Unmarshal(raw, &list) != nil {
return
}
total, warming, capacity, unchecked := len(list.Data), 0, 0, 0
var trouble []string
for _, m := range list.Data {
capacity += m.CampaignLimit
if m.Warmup != nil {
warming++
}
// "unknown" means never checked and never gates sending, so it is not
// a problem to report; only a failing check is.
switch {
case m.Status != "" && m.Status != "active":
trouble = append(trouble, fmt.Sprintf("%s %s", m.Email, io.Red(m.Status)))
case m.AuthState == models.AuthStateFailing:
trouble = append(trouble, fmt.Sprintf("%s %s", m.Email, io.Yellow("SPF, DKIM or DMARC failing")))
case m.AuthState == models.AuthStateUnknown:
unchecked++
}
}
io.Printf("%s\n", io.Gray("MAILBOXES"))
if total == 0 {
io.Printf(" none connected. `warmbly browse mailboxes` opens the dashboard.\n\n")
return
}
io.Printf(" %d connected, %d warming, %d emails/day of campaign capacity\n", total, warming, capacity)
for _, t := range trouble {
io.Printf(" %s %s\n", io.Cross(), t)
}
if len(trouble) == 0 {
io.Printf(" %s all healthy\n", io.Tick())
}
if unchecked > 0 {
io.Printf(" %s\n", io.Gray(fmt.Sprintf("%d never had their authentication checked: `warmbly mailbox recheck <id>`", unchecked)))
}
io.Println()
}
func printCampaigns(io *iostreams.IOStreams, raw json.RawMessage) {
if raw == nil {
return
}
var list struct {
Data []struct {
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if json.Unmarshal(raw, &list) != nil {
return
}
counts := map[string]int{}
var active []string
for _, c := range list.Data {
counts[c.Status]++
if c.Status == "active" || c.Status == "running" {
active = append(active, c.Name)
}
}
io.Printf("%s\n", io.Gray("CAMPAIGNS"))
if len(list.Data) == 0 {
io.Printf(" none yet. `warmbly campaign create --name \"My campaign\"` starts one.\n\n")
return
}
var parts []string
for _, status := range []string{"active", "paused", "draft", "completed"} {
if n := counts[status]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, status))
}
}
io.Printf(" %s\n", strings.Join(parts, ", "))
for i, name := range active {
if i == 5 {
io.Printf(" %s\n", io.Gray(fmt.Sprintf("and %d more sending", len(active)-5)))
break
}
io.Printf(" %s %s\n", io.Green("▸"), name)
}
io.Println()
}
func printInbox(io *iostreams.IOStreams, unread, dashboard json.RawMessage) {
io.Printf("%s\n", io.Gray("INBOX"))
if unread != nil {
var count struct {
Count int `json:"count"`
Total int `json:"total"`
}
if json.Unmarshal(unread, &count) == nil {
n := count.Count
if n == 0 {
n = count.Total
}
if n > 0 {
io.Printf(" %s unread\n", io.Bold(fmt.Sprint(n)))
} else {
io.Printf(" %s nothing unread\n", io.Tick())
}
}
}
if dashboard != nil {
var stats map[string]any
if json.Unmarshal(dashboard, &stats) == nil {
var parts []string
for _, key := range []string{"sent", "opened", "clicked", "replied", "bounced"} {
if v, ok := stats[key]; ok {
parts = append(parts, fmt.Sprintf("%v %s", v, key))
}
}
if len(parts) > 0 {
io.Printf("\n%s\n %s\n", io.Gray("RECENT ACTIVITY"), strings.Join(parts, ", "))
}
}
}
io.Println()
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/cli/config"
"github.com/warmbly/warmbly/internal/cli/update"
"github.com/warmbly/warmbly/internal/version"
)
func newUpgradeCmd(f *Factory) *cobra.Command {
var check bool
cmd := &cobra.Command{
Use: "upgrade",
Aliases: []string{"update", "self-update"},
Short: "Update the CLI to the newest release",
GroupID: groupSetup,
Long: `Replace this binary with the newest published release.
When the CLI came from a package manager it says which command to run instead,
because overwriting a file Homebrew or Scoop owns produces a version that
reverts on their next upgrade.`,
Example: ` $ warmbly upgrade
$ warmbly upgrade --check`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return runUpgrade(c.Context(), f, check)
},
}
cmd.Flags().BoolVar(&check, "check", false, "Only report whether a newer release exists")
return cmd
}
func runUpgrade(ctx context.Context, f *Factory, checkOnly bool) error {
io := f.IO
current := version.String()
io.Errorf("%s\n", io.Gray("Checking for a newer release"))
latest, err := update.LatestVersion(ctx, 15*time.Second)
if err != nil {
return fmt.Errorf("could not reach the release feed: %w", err)
}
// The state file is refreshed here too, so an explicit check silences the
// automatic reminder for the next day.
state := config.LoadState()
state.LastUpdateCheck = time.Now().UTC()
state.LatestVersion = latest
_ = state.Save()
if !update.IsNewer(current, latest) {
if current == latest {
io.Printf("%s warmbly %s is the newest release\n", io.Tick(), io.Bold(current))
} else {
// A dev build is not behind, it is simply not one of ours.
io.Printf("%s running %s; the newest release is %s\n", io.Tick(), io.Bold(current), io.Bold(latest))
}
return nil
}
io.Printf("%s %s is available (you have %s)\n", io.Yellow("↑"), io.Bold(latest), current)
if checkOnly {
return nil
}
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("could not find this binary on disk: %w", err)
}
if cmd := update.DetectMethod(executable).UpgradeCommand(); cmd != "" {
io.Println()
io.Printf("This CLI was installed with a package manager. Upgrade it with:\n\n %s\n", io.Bold(cmd))
return nil
}
if !f.AssumeYes && io.IsStdinTTY() {
ok, cerr := io.Confirm(fmt.Sprintf("Replace %s with %s?", executable, latest), true)
if cerr != nil {
return cerr
}
if !ok {
return errCancelled
}
}
if err := update.Replace(ctx, executable, func(step string) {
io.Errorf("%s %s\n", io.Gray("…"), step)
}); err != nil {
return err
}
io.Printf("%s Upgraded to %s\n", io.Tick(), io.Bold(latest))
return nil
}
// nudgeAboutUpdates prints a one-line reminder after a command has finished,
// at most once a day, and only when someone is watching.
//
// It runs after the command's own output so it never delays a result, and
// every failure is swallowed: a version check is not worth an error message,
// and an air-gapped machine must not pay for one on every run.
func nudgeAboutUpdates(ctx context.Context, f *Factory) {
if !f.IO.IsStdoutTTY() || !f.IO.IsStdinTTY() {
return
}
if f.JSONOut || os.Getenv("WARMBLY_NO_UPDATE_CHECK") != "" || os.Getenv("CI") != "" {
return
}
state := config.LoadState()
if latest := state.LatestVersion; latest != "" && update.IsNewer(version.String(), latest) {
f.IO.Errorf("\n%s %s is available. Run %s\n",
f.IO.Yellow("↑"), f.IO.Bold(latest), f.IO.Bold("warmbly upgrade"))
}
if time.Since(state.LastUpdateCheck) < update.CheckInterval {
return
}
// Two seconds is the whole budget: this happens after the user already has
// what they asked for, and a slow network must not make the CLI feel slow.
latest, err := update.LatestVersion(ctx, 2*time.Second)
if err != nil {
return
}
state.LastUpdateCheck = time.Now().UTC()
state.LatestVersion = latest
_ = state.Save()
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"encoding/json"
"runtime"
"github.com/spf13/cobra"
"github.com/warmbly/warmbly/internal/version"
)
func newVersionCmd(f *Factory) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the CLI's version",
GroupID: groupSetup,
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
info := version.Current()
if f.JSONOut {
payload, err := json.MarshalIndent(map[string]string{
"version": info.Version,
"commit": info.Commit,
"built": info.BuiltAt,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}, "", " ")
if err != nil {
return err
}
f.IO.Println(string(payload))
return nil
}
f.IO.Printf("warmbly %s (%s/%s)\n", info.Version, runtime.GOOS, runtime.GOARCH)
if c := version.ShortCommit(); c != "" {
f.IO.Printf("commit %s\n", c)
}
if info.BuiltAt != "" {
f.IO.Printf("built %s\n", info.BuiltAt)
}
return nil
},
}
}