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