Files
warmbly/cmd/warmblyctl/main.go
T
Matthew Meszaros e143cb0628 feat: give warmblyctl an API-key half so agents and scripts can operate any Warmbly instance, ship in-repo agent skills that teach it, and cut the README quick start and self-hosting sections down to commands plus docs links (#135)
* feat: cut the README quick start and self-hosting sections down to the install command, one paragraph of what it does, and links out to the local development, first-run, deployment and warmblyctl docs pages, dropping the recovery if/then table, the MAIL_TRANSPORT invitation note and the dependency matrix that all duplicate those pages

* feat: give warmblyctl an API-key half so agents and scripts can operate any Warmbly instance including the hosted one, adding a raw passthrough (warmblyctl api get/post/patch/put/delete <path> with --data taking a literal, - or @file, and --idempotency-key) plus eleven typed families (me, campaign, contact, mailbox, inbox, analytics, settings, webhook, apikey, template, crm) driven by one spec table that generates dispatch, flags, per-command help and the request, covering list/get/create/update/delete, sequence steps, sender pools, preflight/start/stop/test-email, contact notes/timeline/import/export, mailbox behavior/verify/send and the six warmup controls, unibox threads/reply/compose/seen/agent-drafts/scheduled sends, outreach settings, webhook secrets and deliveries, and API key self-service, authenticated with Bearer wmbly_ keys from WARMBLY_API_KEY against WARMBLY_API_URL falling back to API_PUBLIC_URL then the hosted service, printing the API's JSON untouched and surfacing the error envelope's code and request_id with Retry-After on 429, while the DB-direct operator commands and their trust model stay exactly as they were

* feat: ship two in-repo agent skills so AI assistants working against Warmbly discover the right warmblyctl half on their own, .claude/skills/warmbly-api teaching product operation over the API commands (key and URL setup including the seeded local dev key, the eleven command families, pagination and error-code and Idempotency-Key conventions, and a sending-safety section that names the six commands that put real mail on the wire and holds agents to preflight before start and the 50/day default cap) and .claude/skills/warmbly-ops teaching instance administration over the DB-direct commands (status --json as the contract to parse, the recovery command table, TTY versus -T piping, Redis-down behaviour, and org export/import handling including --dry-run first and the sensitivity of credential archives), each pointing at the other for what it does not cover, narrowing the .gitignore .claude/ rule to .claude/* with !.claude/skills/ so personal agent state stays local while the skills ship

* feat: document warmblyctl's new API half on the warmblyctl reference page, reframing the intro around the two halves and their two trust models and replacing the 'no HTTP surface and never will' line with the accurate claim that the CLI never serves HTTP while the API commands are a client of the already-gated public API, adding WARMBLY_API_KEY and WARMBLY_API_URL to the environment table with the API_PUBLIC_URL-then-hosted fallback, renaming The commands to The operator commands, and adding an API commands section covering key setup, the eleven command families, the raw /v1 passthrough with curl-style --data forms, the pagination, idempotency-key and Retry-After conventions, a warning callout naming the six commands that put real mail on the wire with preflight-before-start guidance, and a pointer to the shipped .claude/skills agent skills, plus API authentication and permissions links in See also

* feat: move the shipped agent skills from .claude/skills/ to a top-level skills/ directory so they follow the convention other repos use for distributable agent skills rather than living inside Claude Code's personal state directory, restoring the .gitignore .claude/ rule to its original form since nothing tracked lives under it anymore, and updating the warmblyctl reference's For AI agents section to name skills/ and show installing a skill by copying it into the agent's own skills directory or pointing the agent at SKILL.md directly

* feat: clear the Security Scan failure by lifting the two flagged indirect Go modules past their fixed versions, github.com/moby/go-archive from v0.2.0 to v0.3.0 for the CVE-2026-17106 tar path traversal and golang.org/x/mod from v0.37.0 to v0.40.0 for the CVE-2026-56864 and CVE-2026-56865 GOSUMDB and GOPROXY forgery pair, with the x/sys, x/text and x/tools bumps go mod tidy pulls along

* feat: take the Trivy dependency scan off the PR gate and restructure CI the way larger projects do, because a full-repo CVE scan on every pull request goes red the morning any dependency gets a new advisory regardless of what the PR touches, which is exactly how this branch failed on two indirect Go modules it never went near, moving the scan to its own security.yml running weekly, on demand, and on main pushes that change a dependency manifest, pinned to trivy-action 0.36.0 instead of @master, extracting the pnpm+Node+frozen-install boilerplate repeated across the web, admin and site jobs into a .github/actions/setup-pnpm composite action with the store cached per lockfile, and collapsing the CI Status rollup's ten hand-enumerated result checks that had to be edited in two places per new job into a single contains(needs.*.result, ...) expression over failure and cancelled, all validated with actionlint
2026-08-19 20:59:40 -07:00

214 lines
8.2 KiB
Go

// warmblyctl is the CLI for a Warmbly instance. It has two halves with two
// trust models, and the split is deliberate:
//
// The operator commands (status, setup-link, user, org) talk to the database
// directly, so they keep working when signing in does not. Their authorization
// is container or host access, the same trust model as Sentry's createuser,
// Gitea's `admin user create` and authentik's `ak changepassword`. They read
// PRIMARY_DB and REDIS from the environment, which is why running them inside
// the backend container is the documented path.
//
// The API commands (api, campaign, contact, mailbox, inbox, ...) are an HTTP
// client over the public REST API, authorized by an API key, so they drive any
// instance the caller can reach, including the hosted service. They exist so
// agents and scripts can operate the product itself: everything they can do is
// bounded by the key's scopes. The CLI must never SERVE HTTP; being a client
// of the already-gated public API adds no new surface.
//
// docker compose -p warmbly exec backend warmblyctl status
// WARMBLY_API_KEY=wmbly_... warmblyctl campaign list
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
args := os.Args[1:]
if len(args) == 0 {
usage(os.Stderr)
os.Exit(2)
}
if err := dispatch(ctx, args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return
}
// A failing check has already printed itself; repeating it as an error
// line would bury the findings under the tool's own noise.
if errors.Is(err, errChecksFailed) {
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
func dispatch(ctx context.Context, args []string) error {
switch args[0] {
case "help", "-h", "--help":
usage(os.Stdout)
return nil
case "status":
return runStatus(ctx, args[1:])
case "setup-link":
return runSetupLink(ctx, args[1:])
case "hash-password":
return runHashPassword(ctx, args[1:])
case "user":
return runUser(ctx, args[1:])
case "org":
return runOrg(ctx, args[1:])
case "api":
return runAPI(ctx, args[1:])
}
if _, ok := apiFamilies[args[0]]; ok {
return runAPIResource(ctx, args[0], args[1:])
}
return fmt.Errorf("unknown command %q. Run `warmblyctl --help` for the full list.", args[0])
}
// command is one help entry: what it does and one invocation worth copying.
// The example is the whole command line, because the piped ones do not survive
// having a `docker compose exec` prefix bolted on.
type command struct {
name string
summary string
example string
}
const composeExec = "docker compose -p warmbly exec backend warmblyctl "
var commands = []command{
{"status", "Show accounts, admins, registration and mail state, how to get in, and what is wrong", composeExec + "status"},
{"setup-link", "Print a fresh single-use link that claims an instance with no accounts", composeExec + "setup-link"},
{"user create", "Create a user, an organization and a trial, optionally a platform admin", composeExec + "user create --email you@example.com --admin"},
{"user list", "List accounts; --admin answers whether any platform admin survives", composeExec + "user list --admin"},
{"user reset-password", "Print a one-time reset link, or set the password from stdin", composeExec + "user reset-password --email you@example.com"},
{"user grant-admin", "Give an account a platform admin role", composeExec + "user grant-admin --email you@example.com --role super"},
{"user revoke-admin", "Take platform admin away from an account", composeExec + "user revoke-admin --email old@example.com"},
{"user disable-2fa", "Clear an account's TOTP enrollment after a lost authenticator", composeExec + "user disable-2fa --email you@example.com"},
{"hash-password", "Print an argon2 hash for WARMBLY_BOOTSTRAP_PASSWORD_HASH", "printf '%s' 'your-password' | docker compose -p warmbly exec -T backend warmblyctl hash-password"},
{"org list", "List the workspaces on this instance with their id, owner, and size", composeExec + "org list"},
{"org export", "Write a whole workspace to a portable archive file", composeExec + "org export --org you@example.com --out /tmp/workspace.warmbly.zip"},
{"org import", "Apply an archive to a workspace on this instance", composeExec + "org import --org you@example.com --file /tmp/workspace.warmbly.zip"},
}
func usage(w *os.File) {
fmt.Fprint(w, `warmblyctl is the CLI for a Warmbly instance. The operator commands read and
write the database directly, so they work when the sign-in page does not. The
API commands drive a running instance over its public REST API with an API
key, so they work against any instance you hold a key for, hosted included.
Usage:
warmblyctl <command> [flags]
Operator commands (run where PRIMARY_DB is set, normally the backend container):
`)
for _, c := range commands {
fmt.Fprintf(w, " %-20s %s\n", c.name, c.summary)
}
fmt.Fprint(w, "\nAPI commands (need WARMBLY_API_KEY; each family lists its own subcommands):\n")
for _, f := range apiFamilyOrder {
fmt.Fprintf(w, " %-20s %s\n", f, apiFamilies[f])
}
fmt.Fprintf(w, " %-20s %s\n", "api", "Raw passthrough: any method, any /v1 path")
fmt.Fprint(w, "\nExamples:\n")
for _, c := range commands {
fmt.Fprintf(w, " %s\n", c.example)
}
fmt.Fprint(w, ` warmblyctl campaign list
warmblyctl campaign start --id <uuid>
warmblyctl api get "/campaigns?limit=10"
`)
_, _ = io.WriteString(w, `
Anything piped into a container needs exec -T, which is what turns the TTY off.
Anything that prompts needs a TTY, so run it without -T.
Environment:
PRIMARY_DB Postgres connection string. Every operator command except hash-password needs it.
REDIS Redis URL. setup-link needs it, and reset-password needs it to mint a link.
APP_URL Base URL every printed link is built from.
WARMBLY_API_KEY API key (wmbly_...) for the API commands.
WARMBLY_API_URL API base URL for the API commands. Defaults to this instance's
API_PUBLIC_URL, then the hosted service.
org export and org import additionally read the instance's crypto settings,
because a workspace's sealed values have to be opened on the way out and
re-sealed on the way in: KMS_PROVIDER (with KMS_LOCAL_MASTER_KEY or the AWS
settings) and CREDENTIALS_ENCRYPTION_KEY. Run them inside the backend
container, where those are already set.
Exit status:
status exits non-zero when a check is at error severity, so it works as a
post-deploy gate. Use status --quiet from cron: it prints the checks alone,
and nothing at all when none fire. status --json always exits 0, because it
doubles as a liveness probe; read .summary.error there instead.
Run `+"`warmblyctl <command> --help`"+` for one command's flags.
`)
}
// newFlagSet gives every subcommand the same help shape: its flags, then one
// example, because a flag list alone never answers "what do I type". The text
// comes from the commands table so there is one place to keep it honest.
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
c := lookupCommand(name)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "%s.\n\nUsage:\n warmblyctl %s [flags]\n\nFlags:\n", c.summary, name)
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExample:\n %s\n", c.example)
}
return fs
}
func lookupCommand(name string) command {
for _, c := range commands {
if c.name == name {
return c
}
}
return command{name: name, summary: "warmblyctl " + name}
}
// noExtraArgs rejects stray positional arguments so a typo like
// `user create you@example.com` fails loudly instead of creating nothing.
func noExtraArgs(fs *flag.FlagSet) error {
if fs.NArg() == 0 {
return nil
}
return fmt.Errorf("unexpected argument %q. Every value goes in a flag, for example --email you@example.com.", fs.Arg(0))
}
// warn writes an operational note that is not a failure.
func warn(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Warning: "+format+"\n", args...)
}
// indent renders a block of next steps under a heading.
func printSteps(heading string, steps []string) {
if len(steps) == 0 {
return
}
fmt.Printf("\n%s\n", heading)
for _, s := range steps {
for _, line := range strings.Split(s, "\n") {
fmt.Printf(" %s\n", line)
}
}
}