mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 00:05:09 +00:00
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
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
name: Setup pnpm
|
||||
description: pnpm + Node with the pnpm store cached, then a frozen-lockfile install
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
description: The frontend tree to install
|
||||
required: true
|
||||
node-version:
|
||||
description: Node.js version
|
||||
default: "20"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ inputs.working-directory }}/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
+13
-70
@@ -110,20 +110,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: web
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
@@ -148,20 +137,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: admin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: admin
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
@@ -183,20 +161,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: site/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: site
|
||||
node-version: "22"
|
||||
|
||||
- name: Build
|
||||
# Astro project; build catches type errors, broken imports,
|
||||
@@ -367,40 +335,15 @@ jobs:
|
||||
cache-from: type=gha,scope=ci-${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=ci-${{ matrix.service }}
|
||||
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
severity: "CRITICAL,HIGH"
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
|
||||
# The one job to mark required in branch protection. Skipped jobs pass
|
||||
# (their tree did not change); anything failed or cancelled fails it.
|
||||
# Dependency scanning lives in security.yml, off the PR path on purpose.
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images, security]
|
||||
needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check CI status
|
||||
run: |
|
||||
if [[ "${{ needs.go-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.web-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.admin-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.site-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.make-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.frontend-images.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.rust-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.elixir-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.ios-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.security.result }}" == "failure" ]]; then
|
||||
echo "CI failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "CI passed"
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Dependency vulnerability scan, deliberately not a PR gate: a CVE published
|
||||
# overnight is not actionable in whatever PR happens to trip it, so scanning
|
||||
# every PR just makes unrelated work go red. It runs on a schedule and when
|
||||
# dependency manifests change on main; a finding fails the run, which is the
|
||||
# signal to bump the dependency in its own PR.
|
||||
name: Security
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Monday 06:00 UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**/pnpm-lock.yaml"
|
||||
- "**/package-lock.json"
|
||||
- "**/Cargo.lock"
|
||||
- "realtime/mix.lock"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
name: Dependency Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@0.36.0
|
||||
with:
|
||||
scan-type: "fs"
|
||||
scan-ref: "."
|
||||
severity: "CRITICAL,HIGH"
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
@@ -92,21 +92,15 @@ git clone https://github.com/warmbly/warmbly && cd warmbly
|
||||
make dev
|
||||
```
|
||||
|
||||
One command brings up the backing services in Docker, applies migrations, seeds
|
||||
demo data, and starts the backend, worker, and dashboard natively. Open
|
||||
`http://localhost:5173` and log in with `dev@warmbly.com` / `password123`, then
|
||||
read the login code out of Mailpit at `http://localhost:18025` (the native dev
|
||||
stack keeps the emailed code on so the flow stays exercised; a self-hosted
|
||||
install does not). Full setup lives in the
|
||||
Open `http://localhost:5173` and log in with `dev@warmbly.com` / `password123`;
|
||||
the login code lands in Mailpit at `http://localhost:18025`. Every make target,
|
||||
the native services, and how seeding works are in the
|
||||
[local development guide](https://docs.warmbly.com/development/local-development/).
|
||||
|
||||
> [!WARNING]
|
||||
> `make dev` and `make up` share one Docker Compose project, one volume, and one
|
||||
> `warmbly_dev` database, and `make dev` seeds fixture accounts by default. Those
|
||||
> accounts become your instance's accounts, which permanently retires the
|
||||
> first-run claim link `make up` prints. Use `make dev SEED=false` on a database
|
||||
> you intend to self-host from. See
|
||||
> [first run](https://docs.warmbly.com/development/first-run/).
|
||||
> `make dev` and `make up` share one database, and seeded fixture accounts claim
|
||||
> the instance. Planning to self-host from the same machine? Read
|
||||
> [first run](https://docs.warmbly.com/development/first-run/) first.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@@ -120,54 +114,19 @@ git clone https://github.com/warmbly/warmbly && cd warmbly
|
||||
make up
|
||||
```
|
||||
|
||||
That is the whole install. `make up` waits for the backend and prints a one-time
|
||||
link that claims the instance and makes you its admin. Open it, pick a password,
|
||||
and you are in. You need Docker with Compose v2 and about 10 GB of free disk; the
|
||||
first run builds the images once, which takes roughly 6 minutes.
|
||||
That is the whole install: `make up` waits for the backend and prints a one-time
|
||||
link that claims the instance and makes you its admin. You need Docker with
|
||||
Compose v2 and about 10 GB of free disk. If anything looks wrong, `make doctor`
|
||||
prints the instance state and every failing check.
|
||||
|
||||
Nothing else is required: no SMTP relay, no captcha keys, no cloud account, no
|
||||
`.env` to hand-write, and no separate command to grant yourself admin. To change
|
||||
something, `cp .env.example .env` and edit it. The [template](./.env.example)
|
||||
boots as-is and carries the commands that generate your own secrets; set those
|
||||
and `APP_ENV=prod` before anyone else can reach the instance.
|
||||
|
||||
| If | Then |
|
||||
|----|------|
|
||||
| The claim link is gone | It is single use and lasts 24 hours. `make claim` prints a fresh one |
|
||||
| No link was printed at all | The database already has accounts, so the instance is claimed. `make cli ARGS="user create --email you@example.com --admin"` adds you, prompting for a password to set |
|
||||
| You would rather skip the link | Set `WARMBLY_BOOTSTRAP_EMAIL` and `WARMBLY_BOOTSTRAP_PASSWORD_HASH` before the first start and the owner exists when it comes up |
|
||||
| Something is wrong | `make doctor` prints the instance state and every failing check |
|
||||
|
||||
Those all run [`warmblyctl`](https://docs.warmbly.com/development/warmblyctl/),
|
||||
the operator CLI baked into the backend image. It talks to the database directly,
|
||||
so it works when signing in does not.
|
||||
|
||||
> [!NOTE]
|
||||
> Signing in never depends on outbound mail. Platform email defaults to
|
||||
> `MAIL_TRANSPORT=log` under Compose, so password resets and invitations go to the
|
||||
> backend logs until you point `SMTP_*` at a relay. Invitations still work without
|
||||
> one: invite the person under **Settings > Members**, then copy the link from
|
||||
> their row and send it yourself. See
|
||||
> [accounts and access](https://docs.warmbly.com/development/accounts-and-access/).
|
||||
|
||||
**➡️ Follow the [step-by-step self-hosting guide](https://docs.warmbly.com/development/deployment-guide/)**
|
||||
for the full walkthrough: your own secrets, verifying the stack is healthy, mail
|
||||
and single sign-on, HTTPS, connecting Gmail and Microsoft mailboxes, scaling
|
||||
workers, backups, and a troubleshooting table.
|
||||
|
||||
Every external dependency is picked by an environment variable, so you swap in a
|
||||
cloud service only if you want one:
|
||||
|
||||
| Concern | Self-host default | Optional / cloud |
|
||||
|----------------|----------------------------|------------------------------|
|
||||
| Database | PostgreSQL 16 | RDS / Cloud SQL, any Postgres |
|
||||
| Cache | Redis (or Valkey) | ElastiCache |
|
||||
| Event bus | **NATS JetStream** | Kafka (`-tags kafka`) |
|
||||
| Blob storage | **Filesystem** | S3, MinIO, R2, B2 |
|
||||
| KMS / root key | **Local AES master key** | AWS KMS |
|
||||
| Payments | **Off (everything unlocked)** | Stripe |
|
||||
|
||||
Scaling is by mailboxes and workers, not IPs.
|
||||
From here the
|
||||
[self-hosting guide](https://docs.warmbly.com/development/deployment-guide/)
|
||||
covers the rest: your own secrets, production hardening, mail and single
|
||||
sign-on, HTTPS, connecting Gmail and Microsoft mailboxes, scaling workers, and
|
||||
backups. Account recovery and every operator command live in
|
||||
[`warmblyctl`](https://docs.warmbly.com/development/warmblyctl/), the CLI baked
|
||||
into the backend image; it talks to the database directly, so it works when
|
||||
signing in does not.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/apikey"
|
||||
)
|
||||
|
||||
// apiClient talks to a Warmbly instance's public REST API with an API key.
|
||||
// It is how the CLI drives a running instance, including a hosted one, where
|
||||
// the database commands cannot reach. The DB commands stay for recovery; this
|
||||
// path is for day-to-day operation, which makes it the surface agents script.
|
||||
type apiClient struct {
|
||||
base string
|
||||
key string
|
||||
http *http.Client
|
||||
debug bool
|
||||
}
|
||||
|
||||
// apiEndpoint resolves the API base URL an agent most plausibly means:
|
||||
// an explicit WARMBLY_API_URL, then the instance's own API_PUBLIC_URL when the
|
||||
// command runs where the backend's environment is present, then the hosted
|
||||
// service.
|
||||
func apiEndpoint() string {
|
||||
if v := strings.TrimSpace(os.Getenv("WARMBLY_API_URL")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("API_PUBLIC_URL")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return "https://api.warmbly.com"
|
||||
}
|
||||
|
||||
func newAPIClient() (*apiClient, error) {
|
||||
key := strings.TrimSpace(os.Getenv("WARMBLY_API_KEY"))
|
||||
if key == "" {
|
||||
return nil, errors.New("WARMBLY_API_KEY is not set, so there is no key to call the API with.\nCreate one under Settings > API keys (or `warmblyctl api keys` on an instance you can already reach), then:\n export WARMBLY_API_KEY=wmbly_...\n export WARMBLY_API_URL=https://api.your-instance.com # omit for the hosted service")
|
||||
}
|
||||
if !strings.HasPrefix(key, apikey.KeyPrefix) {
|
||||
return nil, fmt.Errorf("WARMBLY_API_KEY does not look like a Warmbly API key: it should start with %q.", apikey.KeyPrefix)
|
||||
}
|
||||
return &apiClient{
|
||||
base: apiEndpoint(),
|
||||
key: key,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
debug: os.Getenv("WARMBLYCTL_DEBUG") != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// apiError is the backend's stable error envelope. Every field is part of the
|
||||
// public contract, so they are safe to surface verbatim.
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// do performs one request. path is relative to /v1 unless it already names a
|
||||
// version. A non-2xx response comes back as an error carrying the backend's
|
||||
// own code and request id, so an agent can branch without parsing prose.
|
||||
func (c *apiClient) do(ctx context.Context, method, path string, query url.Values, body any, idempotencyKey string) (json.RawMessage, error) {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if !strings.HasPrefix(path, "/v1/") && path != "/v1" {
|
||||
path = "/v1" + path
|
||||
}
|
||||
full := c.base + path
|
||||
if len(query) > 0 {
|
||||
full += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
switch b := body.(type) {
|
||||
case json.RawMessage:
|
||||
reader = bytes.NewReader(b)
|
||||
case []byte:
|
||||
reader = bytes.NewReader(b)
|
||||
default:
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encoding the request body: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
req.Header.Set("Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
fmt.Fprintf(os.Stderr, "> %s %s\n", method, full)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not reach the API at %s: %w\nSet WARMBLY_API_URL to your instance's API base URL, or omit it for the hosted service.", c.base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
var apiErr apiError
|
||||
if json.Unmarshal(payload, &apiErr) == nil && (apiErr.Message != "" || apiErr.Code != "") {
|
||||
msg := apiErr.Message
|
||||
if msg == "" {
|
||||
msg = apiErr.Error
|
||||
}
|
||||
detail := fmt.Sprintf("%s %s failed (%d %s): %s", method, path, resp.StatusCode, apiErr.Code, msg)
|
||||
if apiErr.RequestID != "" {
|
||||
detail += " (request " + apiErr.RequestID + ")"
|
||||
}
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
if retry := resp.Header.Get("Retry-After"); retry != "" {
|
||||
detail += ". Rate limited; retry after " + retry + "s."
|
||||
}
|
||||
}
|
||||
return nil, errors.New(detail)
|
||||
}
|
||||
return nil, fmt.Errorf("%s %s failed with HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
// printJSON writes the API's response for a human and a parser alike: the
|
||||
// payload is already JSON, so it is re-indented and passed through untouched.
|
||||
func printJSON(payload json.RawMessage) error {
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
fmt.Println("{}")
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, payload, "", " "); err != nil {
|
||||
// Not JSON (some endpoints stream files); pass it through as-is.
|
||||
_, werr := os.Stdout.Write(payload)
|
||||
if werr == nil {
|
||||
fmt.Println()
|
||||
}
|
||||
return werr
|
||||
}
|
||||
fmt.Println(buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// readBodyArg turns a --data value into a request body: a JSON literal, `-`
|
||||
// for stdin, or @path for a file, the same conventions curl taught everyone.
|
||||
func readBodyArg(data string) (json.RawMessage, error) {
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []byte
|
||||
switch {
|
||||
case data == "-":
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the body from stdin: %w", err)
|
||||
}
|
||||
raw = b
|
||||
case strings.HasPrefix(data, "@"):
|
||||
b, err := os.ReadFile(strings.TrimPrefix(data, "@"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the body file: %w", err)
|
||||
}
|
||||
raw = b
|
||||
default:
|
||||
raw = []byte(data)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
return nil, errors.New("the request body is not valid JSON. Pass a JSON literal, `-` for stdin, or @file.")
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
|
||||
// runAPI is the raw passthrough: any method, any path, so nothing the API can
|
||||
// do is out of the CLI's reach even before a typed command exists for it.
|
||||
func runAPI(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
apiUsage(os.Stderr)
|
||||
return errors.New("`api` needs a method and a path. Pick a form from the list above.")
|
||||
}
|
||||
|
||||
method := strings.ToUpper(args[0])
|
||||
switch method {
|
||||
case "HELP", "-H", "--HELP":
|
||||
apiUsage(os.Stdout)
|
||||
return nil
|
||||
case "GET", "POST", "PATCH", "PUT", "DELETE":
|
||||
default:
|
||||
apiUsage(os.Stderr)
|
||||
return fmt.Errorf("unknown method %q. Use get, post, patch, put or delete.", args[0])
|
||||
}
|
||||
|
||||
fs := newFlagSet("api")
|
||||
data := fs.String("data", "", "JSON request body: a literal, `-` for stdin, or @file")
|
||||
idem := fs.String("idempotency-key", "", "Idempotency-Key header for a safely retryable write")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
return errors.New("missing the request path, for example `warmblyctl api get /campaigns`.")
|
||||
}
|
||||
if fs.NArg() > 1 {
|
||||
return fmt.Errorf("unexpected argument %q. Put query parameters in the path itself: /campaigns?limit=10", fs.Arg(1))
|
||||
}
|
||||
|
||||
rawPath := fs.Arg(0)
|
||||
parsed, err := url.Parse(rawPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not a usable path: %w", rawPath, err)
|
||||
}
|
||||
|
||||
body, err := readBodyArg(*data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil && method == "GET" {
|
||||
return errors.New("a GET request carries no body. Put parameters in the query string instead.")
|
||||
}
|
||||
|
||||
client, err := newAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := client.do(ctx, method, parsed.Path, parsed.Query(), body, *idem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(payload)
|
||||
}
|
||||
|
||||
func apiUsage(w *os.File) {
|
||||
fmt.Fprint(w, `Call the public REST API of a Warmbly instance directly.
|
||||
|
||||
Usage:
|
||||
warmblyctl api <get|post|patch|put|delete> <path> [--data JSON] [--idempotency-key KEY]
|
||||
|
||||
Paths are relative to /v1. Examples:
|
||||
warmblyctl api get "/campaigns?limit=10"
|
||||
warmblyctl api post /contacts --data '{"email":"jane@example.com"}'
|
||||
warmblyctl api patch /campaigns/<id> --data @changes.json
|
||||
warmblyctl api delete /webhooks/<id>
|
||||
|
||||
Environment:
|
||||
WARMBLY_API_KEY The API key (starts with wmbly_). Required.
|
||||
WARMBLY_API_URL API base URL. Defaults to the instance's own API_PUBLIC_URL
|
||||
when run inside the backend, then https://api.warmbly.com.
|
||||
|
||||
The response body is printed to stdout as JSON. A non-2xx response exits 1 and
|
||||
prints the API's machine-readable error code and request id to stderr.
|
||||
|
||||
The typed commands (campaign, contact, mailbox, inbox, ...) cover the common
|
||||
operations with flags; this passthrough covers everything else.
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bodyMode says what a typed command does with --data.
|
||||
type bodyMode int
|
||||
|
||||
const (
|
||||
bodyNone bodyMode = iota // the endpoint takes no body
|
||||
bodyOptional // --data may be given; an empty one is sent as {}
|
||||
bodyRequired // --data must be given
|
||||
)
|
||||
|
||||
// apiSpec is one typed command over the public API. The table below is the
|
||||
// entire definition of the resource commands: dispatch, flags, help and the
|
||||
// request all come from it, so adding an endpoint is adding a row.
|
||||
type apiSpec struct {
|
||||
name string // "campaign start"
|
||||
summary string // one line for help output
|
||||
method string // HTTP method
|
||||
path string // /v1-relative path, may contain {id} and {child}
|
||||
body bodyMode // what --data means here
|
||||
child string // flag name filling {child}, e.g. "step"
|
||||
query []string // query parameters exposed as flags
|
||||
sends bool // true when the command can put real mail on the wire
|
||||
}
|
||||
|
||||
var apiSpecs = []apiSpec{
|
||||
{name: "me", summary: "Show who the API key is: user, organization, and granted scopes", method: "GET", path: "/me"},
|
||||
|
||||
// Campaigns.
|
||||
{name: "campaign list", summary: "List campaigns", method: "GET", path: "/campaigns", query: []string{"limit", "cursor", "q", "status", "folder"}},
|
||||
{name: "campaign get", summary: "Get one campaign", method: "GET", path: "/campaigns/{id}"},
|
||||
{name: "campaign overview", summary: "Status and folder counts across all campaigns", method: "GET", path: "/campaigns-overview"},
|
||||
{name: "campaign create", summary: "Create a campaign", method: "POST", path: "/campaigns", body: bodyRequired},
|
||||
{name: "campaign update", summary: "Update a campaign", method: "PATCH", path: "/campaigns/{id}", body: bodyRequired},
|
||||
{name: "campaign delete", summary: "Delete a campaign", method: "DELETE", path: "/campaigns/{id}"},
|
||||
{name: "campaign steps", summary: "List a campaign's sequence steps", method: "GET", path: "/campaigns/{id}/steps"},
|
||||
{name: "campaign add-step", summary: "Add a sequence step", method: "POST", path: "/campaigns/{id}/steps", body: bodyRequired},
|
||||
{name: "campaign update-step", summary: "Update a sequence step", method: "PATCH", path: "/campaigns/{id}/steps/{child}", body: bodyRequired, child: "step"},
|
||||
{name: "campaign delete-step", summary: "Delete a sequence step", method: "DELETE", path: "/campaigns/{id}/steps/{child}", child: "step"},
|
||||
{name: "campaign senders", summary: "Show the campaign's sender pool and weights", method: "GET", path: "/campaigns/{id}/senders"},
|
||||
{name: "campaign set-senders", summary: "Replace the campaign's sender pool", method: "PUT", path: "/campaigns/{id}/senders", body: bodyRequired},
|
||||
{name: "campaign preflight", summary: "Run the pre-send checks without sending", method: "POST", path: "/campaigns/{id}/preflight"},
|
||||
{name: "campaign test-email", summary: "Send the campaign as a test to an address you name", method: "POST", path: "/campaigns/{id}/test-email", body: bodyRequired, sends: true},
|
||||
{name: "campaign start", summary: "Start the campaign. This sends real mail", method: "POST", path: "/campaigns/{id}/start", sends: true},
|
||||
{name: "campaign stop", summary: "Stop the campaign", method: "POST", path: "/campaigns/{id}/stop"},
|
||||
{name: "campaign logs", summary: "The campaign's send log", method: "GET", path: "/campaigns/{id}/logs", query: []string{"limit", "cursor"}},
|
||||
|
||||
// Contacts.
|
||||
{name: "contact list", summary: "List or search contacts; --data carries the filter body", method: "POST", path: "/contacts/search", body: bodyOptional, query: []string{"limit", "cursor"}},
|
||||
{name: "contact get", summary: "Get one contact with suppression state", method: "GET", path: "/contacts/{id}"},
|
||||
{name: "contact lookup", summary: "Resolve a contact by email address", method: "GET", path: "/contacts/lookup", query: []string{"email"}},
|
||||
{name: "contact create", summary: "Create a contact", method: "POST", path: "/contacts", body: bodyRequired},
|
||||
{name: "contact update", summary: "Update a contact", method: "PATCH", path: "/contacts/{id}", body: bodyRequired},
|
||||
{name: "contact delete", summary: "Delete a contact", method: "DELETE", path: "/contacts/{id}"},
|
||||
{name: "contact timeline", summary: "Everything that happened to a contact, newest first", method: "GET", path: "/contacts/{id}/timeline", query: []string{"limit", "cursor"}},
|
||||
{name: "contact emails", summary: "Emails sent to a contact", method: "GET", path: "/contacts/{id}/emails", query: []string{"limit", "cursor"}},
|
||||
{name: "contact notes", summary: "List a contact's notes", method: "GET", path: "/contacts/{id}/notes"},
|
||||
{name: "contact add-note", summary: "Add a note to a contact", method: "POST", path: "/contacts/{id}/notes", body: bodyRequired},
|
||||
{name: "contact custom-fields", summary: "The distinct custom field keys in use", method: "GET", path: "/contacts/custom-fields"},
|
||||
{name: "contact import-preview", summary: "Preview a bulk import without writing", method: "POST", path: "/contacts/import/preview", body: bodyRequired},
|
||||
{name: "contact import-commit", summary: "Commit a previewed bulk import", method: "POST", path: "/contacts/import/commit", body: bodyRequired},
|
||||
{name: "contact export", summary: "Export contacts", method: "POST", path: "/contacts/export", body: bodyOptional},
|
||||
|
||||
// Mailboxes (email accounts).
|
||||
{name: "mailbox list", summary: "List connected mailboxes", method: "GET", path: "/emails", query: []string{"limit", "cursor", "q"}},
|
||||
{name: "mailbox get", summary: "Get one mailbox", method: "GET", path: "/emails/{id}"},
|
||||
{name: "mailbox update", summary: "Update mailbox settings (limits, tags, timezone, signature)", method: "PATCH", path: "/emails/{id}", body: bodyRequired},
|
||||
{name: "mailbox delete", summary: "Disconnect a mailbox", method: "DELETE", path: "/emails/{id}"},
|
||||
{name: "mailbox auth-check", summary: "Check the mailbox's SPF, DKIM and DMARC", method: "GET", path: "/emails/{id}/auth-check"},
|
||||
{name: "mailbox sync", summary: "The mailbox's sync state and backfill progress", method: "GET", path: "/emails/{id}/sync"},
|
||||
{name: "mailbox behavior", summary: "The mailbox's human-sending ranges", method: "GET", path: "/emails/{id}/behavior"},
|
||||
{name: "mailbox set-behavior", summary: "Update the mailbox's sending behaviour", method: "PUT", path: "/emails/{id}/behavior", body: bodyRequired},
|
||||
{name: "mailbox verify", summary: "Verify an email address without sending", method: "POST", path: "/emails/verify", body: bodyRequired},
|
||||
{name: "mailbox send", summary: "Send one email from this mailbox. This sends real mail", method: "POST", path: "/emails/{id}/send", body: bodyRequired, sends: true},
|
||||
{name: "mailbox warmup-start", summary: "Start warming the mailbox", method: "POST", path: "/emails/{id}/warmup/start"},
|
||||
{name: "mailbox warmup-pause", summary: "Pause warmup", method: "POST", path: "/emails/{id}/warmup/pause"},
|
||||
{name: "mailbox warmup-resume", summary: "Resume warmup", method: "POST", path: "/emails/{id}/warmup/resume"},
|
||||
{name: "mailbox warmup-stop", summary: "Stop warmup", method: "POST", path: "/emails/{id}/warmup/stop"},
|
||||
{name: "mailbox warmup-status", summary: "The mailbox's warmup pool ban status", method: "GET", path: "/emails/{id}/warmup/ban-status"},
|
||||
|
||||
// Unified inbox.
|
||||
{name: "inbox list", summary: "List inbox messages", method: "GET", path: "/unibox", query: []string{"limit", "cursor", "address", "direction", "from", "subject", "unseen", "awaiting_reply", "since", "until"}},
|
||||
{name: "inbox count", summary: "The unseen message count", method: "GET", path: "/unibox/count"},
|
||||
{name: "inbox overview", summary: "Per-mailbox and per-tag inbox rollup", method: "GET", path: "/unibox/overview"},
|
||||
{name: "inbox thread", summary: "One conversation thread", method: "GET", path: "/unibox/thread", query: []string{"thread_id", "email_id", "limit", "cursor"}},
|
||||
{name: "inbox seen", summary: "Mark messages seen or unseen", method: "PATCH", path: "/unibox/seen", body: bodyRequired},
|
||||
{name: "inbox reply", summary: "Reply in a thread. This sends real mail", method: "POST", path: "/unibox/reply", body: bodyRequired, sends: true},
|
||||
{name: "inbox compose", summary: "Compose a new email. This sends real mail", method: "POST", path: "/unibox/compose", body: bodyRequired, sends: true},
|
||||
{name: "inbox drafts", summary: "List the AI agent's drafts awaiting approval", method: "GET", path: "/unibox/agent-drafts"},
|
||||
{name: "inbox approve-draft", summary: "Approve an agent draft, which sends it", method: "POST", path: "/unibox/agent-drafts/{id}/approve", sends: true},
|
||||
{name: "inbox discard-draft", summary: "Discard an agent draft", method: "POST", path: "/unibox/agent-drafts/{id}/discard"},
|
||||
{name: "inbox scheduled", summary: "List scheduled sends", method: "GET", path: "/unibox/scheduled"},
|
||||
{name: "inbox cancel-scheduled", summary: "Cancel a scheduled send", method: "DELETE", path: "/unibox/scheduled/{id}"},
|
||||
|
||||
// Analytics and audit.
|
||||
{name: "analytics dashboard", summary: "The dashboard numbers", method: "GET", path: "/analytics/dashboard"},
|
||||
{name: "analytics deliverability", summary: "Bounces, complaints and placement", method: "GET", path: "/analytics/deliverability"},
|
||||
{name: "analytics warmup", summary: "Warmup analytics", method: "GET", path: "/analytics/warmup"},
|
||||
{name: "analytics accounts", summary: "Per-mailbox analytics", method: "GET", path: "/analytics/accounts"},
|
||||
{name: "analytics account", summary: "One mailbox's analytics", method: "GET", path: "/analytics/accounts/{id}"},
|
||||
{name: "analytics campaign", summary: "One campaign's analytics", method: "GET", path: "/analytics/campaigns/{id}"},
|
||||
{name: "analytics campaign-daily", summary: "One campaign's daily series", method: "GET", path: "/analytics/campaigns/{id}/daily"},
|
||||
{name: "analytics campaign-hourly", summary: "One campaign's hourly series", method: "GET", path: "/analytics/campaigns/{id}/hourly"},
|
||||
{name: "analytics usage", summary: "API and plan usage", method: "GET", path: "/analytics/usage"},
|
||||
{name: "analytics audit-logs", summary: "The organization's audit trail", method: "GET", path: "/audit-logs", query: []string{"limit", "cursor"}},
|
||||
|
||||
// Organization-wide sending settings.
|
||||
{name: "settings outreach", summary: "The organization's outreach and suppression settings", method: "GET", path: "/outreach/settings"},
|
||||
{name: "settings set-outreach", summary: "Update the outreach settings", method: "PATCH", path: "/outreach/settings", body: bodyRequired},
|
||||
|
||||
// Webhooks.
|
||||
{name: "webhook list", summary: "List webhook endpoints", method: "GET", path: "/webhooks"},
|
||||
{name: "webhook create", summary: "Create a webhook endpoint", method: "POST", path: "/webhooks", body: bodyRequired},
|
||||
{name: "webhook update", summary: "Update a webhook endpoint", method: "PATCH", path: "/webhooks/{id}", body: bodyRequired},
|
||||
{name: "webhook delete", summary: "Delete a webhook endpoint", method: "DELETE", path: "/webhooks/{id}"},
|
||||
{name: "webhook verify", summary: "Send a verification ping to the endpoint", method: "POST", path: "/webhooks/{id}/verify"},
|
||||
{name: "webhook rotate-secret", summary: "Rotate the endpoint's signing secret", method: "POST", path: "/webhooks/{id}/rotate-secret"},
|
||||
{name: "webhook deliveries", summary: "Recent deliveries across endpoints", method: "GET", path: "/webhooks/deliveries", query: []string{"limit", "cursor"}},
|
||||
{name: "webhook event-types", summary: "Every event type a webhook can subscribe to", method: "GET", path: "/webhooks/event-types"},
|
||||
|
||||
// API keys (self-service).
|
||||
{name: "apikey list", summary: "List the organization's API keys", method: "GET", path: "/api-keys"},
|
||||
{name: "apikey get", summary: "Get one API key", method: "GET", path: "/api-keys/{id}"},
|
||||
{name: "apikey create", summary: "Create an API key; the secret is only ever in this response", method: "POST", path: "/api-keys", body: bodyRequired},
|
||||
{name: "apikey update", summary: "Update an API key's name, scopes or restrictions", method: "PATCH", path: "/api-keys/{id}", body: bodyRequired},
|
||||
{name: "apikey revoke", summary: "Revoke an API key", method: "DELETE", path: "/api-keys/{id}"},
|
||||
{name: "apikey permissions", summary: "Every grantable scope with its bit value", method: "GET", path: "/api-keys/permissions"},
|
||||
|
||||
// Templates.
|
||||
{name: "template list", summary: "List reply templates", method: "GET", path: "/templates"},
|
||||
{name: "template get", summary: "Get one template", method: "GET", path: "/templates/{id}"},
|
||||
{name: "template create", summary: "Create a template", method: "POST", path: "/templates", body: bodyRequired},
|
||||
{name: "template update", summary: "Update a template", method: "PATCH", path: "/templates/{id}", body: bodyRequired},
|
||||
{name: "template delete", summary: "Delete a template", method: "DELETE", path: "/templates/{id}"},
|
||||
|
||||
// CRM.
|
||||
{name: "crm pipelines", summary: "List pipelines with their stages", method: "GET", path: "/crm/pipelines"},
|
||||
{name: "crm deals", summary: "Search deals; --data carries the filter body", method: "POST", path: "/crm/deals/search", body: bodyOptional},
|
||||
{name: "crm tasks", summary: "Search CRM tasks; --data carries the filter body", method: "POST", path: "/crm/tasks/search", body: bodyOptional},
|
||||
}
|
||||
|
||||
// apiFamilyOrder keeps the top-level help stable; maps iterate randomly.
|
||||
var apiFamilyOrder = []string{
|
||||
"me", "campaign", "contact", "mailbox", "inbox", "analytics",
|
||||
"settings", "webhook", "apikey", "template", "crm",
|
||||
}
|
||||
|
||||
// apiFamilies drives top-level dispatch and help for the typed commands.
|
||||
var apiFamilies = map[string]string{
|
||||
"me": "Who the API key is",
|
||||
"campaign": "Campaigns and their sequences",
|
||||
"contact": "Contacts, notes, and imports",
|
||||
"mailbox": "Connected mailboxes and warmup",
|
||||
"inbox": "The unified inbox",
|
||||
"analytics": "Analytics and the audit trail",
|
||||
"settings": "Organization outreach settings",
|
||||
"webhook": "Webhook endpoints",
|
||||
"apikey": "API keys",
|
||||
"template": "Reply templates",
|
||||
"crm": "Pipelines, deals, and CRM tasks",
|
||||
}
|
||||
|
||||
// runAPIResource runs one typed command: `warmblyctl campaign start --id ...`.
|
||||
func runAPIResource(ctx context.Context, family string, args []string) error {
|
||||
// `me` has no subcommands; everything else does.
|
||||
name := family
|
||||
if family != "me" {
|
||||
if len(args) == 0 {
|
||||
apiFamilyUsage(os.Stderr, family)
|
||||
return fmt.Errorf("`%s` needs a subcommand. Pick one from the list above.", family)
|
||||
}
|
||||
if args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
|
||||
apiFamilyUsage(os.Stdout, family)
|
||||
return nil
|
||||
}
|
||||
name = family + " " + args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
spec, ok := lookupAPISpec(name)
|
||||
if !ok {
|
||||
apiFamilyUsage(os.Stderr, family)
|
||||
return fmt.Errorf("unknown subcommand `%s`. Pick one from the list above.", name)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.Usage = func() { apiSpecUsage(os.Stderr, spec) }
|
||||
|
||||
var id, child, data, idem *string
|
||||
if strings.Contains(spec.path, "{id}") {
|
||||
id = fs.String("id", "", "the resource id (required)")
|
||||
}
|
||||
if spec.child != "" {
|
||||
child = fs.String(spec.child, "", "the "+spec.child+" id (required)")
|
||||
}
|
||||
if spec.body != bodyNone {
|
||||
data = fs.String("data", "", "JSON request body: a literal, `-` for stdin, or @file")
|
||||
}
|
||||
if spec.method != "GET" {
|
||||
idem = fs.String("idempotency-key", "", "Idempotency-Key header for a safely retryable write")
|
||||
}
|
||||
queryVals := make(map[string]*string, len(spec.query))
|
||||
for _, q := range spec.query {
|
||||
queryVals[q] = fs.String(q, "", "query parameter "+q)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := noExtraArgs(fs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := spec.path
|
||||
if id != nil {
|
||||
if strings.TrimSpace(*id) == "" {
|
||||
return fmt.Errorf("--id is required. Example:\n %s", specExample(spec))
|
||||
}
|
||||
path = strings.ReplaceAll(path, "{id}", url.PathEscape(strings.TrimSpace(*id)))
|
||||
}
|
||||
if child != nil {
|
||||
if strings.TrimSpace(*child) == "" {
|
||||
return fmt.Errorf("--%s is required. Example:\n %s", spec.child, specExample(spec))
|
||||
}
|
||||
path = strings.ReplaceAll(path, "{child}", url.PathEscape(strings.TrimSpace(*child)))
|
||||
}
|
||||
|
||||
var body json.RawMessage
|
||||
if data != nil {
|
||||
parsed, err := readBodyArg(*data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = parsed
|
||||
}
|
||||
if body == nil && spec.body == bodyRequired {
|
||||
return fmt.Errorf("--data is required: this command writes, and the body carries what to write. Example:\n %s", specExample(spec))
|
||||
}
|
||||
if body == nil && spec.body == bodyOptional {
|
||||
body = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
for k, v := range queryVals {
|
||||
if strings.TrimSpace(*v) != "" {
|
||||
query.Set(k, strings.TrimSpace(*v))
|
||||
}
|
||||
}
|
||||
|
||||
idemKey := ""
|
||||
if idem != nil {
|
||||
idemKey = *idem
|
||||
}
|
||||
|
||||
client, err := newAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := client.do(ctx, spec.method, path, query, bodyOrNil(body), idemKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(payload)
|
||||
}
|
||||
|
||||
// bodyOrNil keeps a nil RawMessage from being sent as the literal "null".
|
||||
func bodyOrNil(body json.RawMessage) any {
|
||||
if body == nil {
|
||||
return nil
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func lookupAPISpec(name string) (apiSpec, bool) {
|
||||
for _, s := range apiSpecs {
|
||||
if s.name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return apiSpec{}, false
|
||||
}
|
||||
|
||||
// specExample builds a copy-pasteable invocation from the spec itself, so the
|
||||
// help never drifts from what the command actually accepts.
|
||||
func specExample(s apiSpec) string {
|
||||
parts := []string{"warmblyctl", s.name}
|
||||
if strings.Contains(s.path, "{id}") {
|
||||
parts = append(parts, "--id <uuid>")
|
||||
}
|
||||
if s.child != "" {
|
||||
parts = append(parts, "--"+s.child+" <uuid>")
|
||||
}
|
||||
if s.body == bodyRequired {
|
||||
parts = append(parts, `--data '{...}'`)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func apiFamilyUsage(w *os.File, family string) {
|
||||
fmt.Fprintf(w, "%s, over the public API.\n\nUsage:\n warmblyctl %s <subcommand> [flags]\n\nSubcommands:\n", apiFamilies[family], family)
|
||||
for _, s := range apiSpecs {
|
||||
if !strings.HasPrefix(s.name, family+" ") {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %-18s %s\n", strings.TrimPrefix(s.name, family+" "), s.summary)
|
||||
}
|
||||
fmt.Fprintf(w, "\nEvery command prints the API's JSON response. These need WARMBLY_API_KEY, and\nWARMBLY_API_URL when the instance is not the hosted service.\nRun `warmblyctl %s <subcommand> --help` for one command's flags.\n", family)
|
||||
}
|
||||
|
||||
func apiSpecUsage(w *os.File, s apiSpec) {
|
||||
fmt.Fprintf(w, "%s.\n\nUsage:\n %s\n\nCalls:\n %s /v1%s\n", s.summary, specExample(s), s.method, s.path)
|
||||
if len(s.query) > 0 {
|
||||
fmt.Fprintf(w, "\nQuery flags: --%s\n", strings.Join(s.query, ", --"))
|
||||
}
|
||||
if s.body == bodyOptional {
|
||||
fmt.Fprint(w, "\n--data is optional; omitting it sends {}.\n")
|
||||
}
|
||||
if s.sends {
|
||||
fmt.Fprint(w, "\nThis command puts real mail on the wire.\n")
|
||||
}
|
||||
}
|
||||
+41
-15
@@ -1,16 +1,22 @@
|
||||
// warmblyctl is the operator CLI for a Warmbly instance. It answers "what
|
||||
// state is this install in" and "how do I get back in" by talking to the
|
||||
// database directly, so it keeps working when signing in does not.
|
||||
// warmblyctl is the CLI for a Warmbly instance. It has two halves with two
|
||||
// trust models, and the split is deliberate:
|
||||
//
|
||||
// Authorization is container or host access, the same trust model as Sentry's
|
||||
// createuser, Gitea's `admin user create` and authentik's `ak changepassword`.
|
||||
// It must never grow an HTTP surface.
|
||||
// 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.
|
||||
//
|
||||
// It reads PRIMARY_DB and REDIS from the environment, which is why running it
|
||||
// inside the backend container is the documented path: the environment there is
|
||||
// already correct.
|
||||
// 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 (
|
||||
@@ -63,6 +69,11 @@ func dispatch(ctx context.Context, args []string) error {
|
||||
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])
|
||||
}
|
||||
@@ -94,31 +105,46 @@ var commands = []command{
|
||||
}
|
||||
|
||||
func usage(w *os.File) {
|
||||
fmt.Fprint(w, `warmblyctl is the operator CLI for this Warmbly instance. It reads and writes
|
||||
the database directly, so it works when the sign-in page does not.
|
||||
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]
|
||||
|
||||
Commands:
|
||||
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 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.
|
||||
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
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
---
|
||||
title: warmblyctl
|
||||
description: The operator CLI for a Warmbly instance. Every command and flag, how to run it in each runtime, how passwords are set, and how to get back in when nobody can sign in.
|
||||
description: The CLI for a Warmbly instance. The operator commands for accounts, health and recovery, and the API commands that let scripts and AI agents drive campaigns, contacts, mailboxes and the inbox with an API key.
|
||||
---
|
||||
|
||||
`warmblyctl` is the operator CLI for a Warmbly instance. It answers two questions: what state is this install in, and how do I get back in. It reads and writes the database directly, so it keeps working when the sign-in page does not.
|
||||
`warmblyctl` is the CLI for a Warmbly instance, and it has two halves with two trust models.
|
||||
|
||||
Authorization is container or host access. That is the same trust model as Sentry's `createuser`, Gitea's `admin user create` and authentik's `ak changepassword`, and it is the right one when the identity system is the thing that is broken. The CLI has no HTTP surface and never will.
|
||||
The operator commands answer two questions: what state is this install in, and how do I get back in. They read and write the database directly, so they keep working when the sign-in page does not. Their authorization is container or host access, the same trust model as Sentry's `createuser`, Gitea's `admin user create` and authentik's `ak changepassword`, and it is the right one when the identity system is the thing that is broken.
|
||||
|
||||
The [API commands](#the-api-commands) drive a running instance over its public REST API with an API key, so they work from any machine against any instance you hold a key for, the hosted service included. They exist so scripts and AI agents can operate the product itself: campaigns, contacts, mailboxes, the inbox, settings. Everything they can do is bounded by the key's scopes.
|
||||
|
||||
The CLI never serves HTTP. The API commands are a client of the already-gated public API, which adds no new surface to your instance.
|
||||
|
||||
## Running it
|
||||
|
||||
@@ -51,10 +55,12 @@ A command that would set a password refuses on a non-TTY unless you passed `--pa
|
||||
| `APP_URL` | every printed link and sign-in hint | Links are built against `https://app.warmbly.com`, which is the hosted service and not your instance |
|
||||
| `KMS_PROVIDER` and its key | `org export`, `org import` | The command stops: a workspace's sealed values cannot be opened, so an archive would be useless |
|
||||
| `CREDENTIALS_ENCRYPTION_KEY` | `org export`, `org import` | A warning, and mailbox credentials are neither read nor written. Everything else still moves |
|
||||
| `WARMBLY_API_KEY` | every API command | The command stops and explains where a key comes from |
|
||||
| `WARMBLY_API_URL` | every API command | Falls back to the instance's own `API_PUBLIC_URL`, then the hosted service |
|
||||
|
||||
Inside the backend container all of these are already set. Outside it, export them first, and match `AUTH_SECRET` to the backend's exactly.
|
||||
Inside the backend container all of these are already set, except the API key, which is yours. Outside it, export what the command needs, and match `AUTH_SECRET` to the backend's exactly.
|
||||
|
||||
## The commands
|
||||
## The operator commands
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
@@ -71,7 +77,7 @@ Inside the backend container all of these are already set. Outside it, export th
|
||||
| [`org export`](#org-export) | Writes a whole workspace to a portable archive file |
|
||||
| [`org import`](#org-import) | Applies an archive to a workspace on this instance |
|
||||
|
||||
`warmblyctl --help` lists them, and `warmblyctl <command> --help` prints one command's flags with an example.
|
||||
`warmblyctl --help` lists them, and `warmblyctl <command> --help` prints one command's flags with an example. The [API commands](#the-api-commands) are further down, because they authenticate differently.
|
||||
|
||||
## status
|
||||
|
||||
@@ -340,6 +346,67 @@ The whole import runs in one transaction: if any part fails, nothing lands and t
|
||||
|
||||
Billing history, plan overrides, worker placement, mailbox sync checkpoints and warmup pool membership are exported for the record but never applied: each belongs to the instance rather than to the workspace. [Export and import](/guides/workspace-export-import/) has the full table.
|
||||
|
||||
## The API commands
|
||||
|
||||
Everything in this section talks to the public REST API with an API key, so it runs from anywhere: your laptop, a CI job, an agent's sandbox. It needs no database access and works against the hosted service and self-hosted instances alike.
|
||||
|
||||
```bash
|
||||
export WARMBLY_API_KEY=wmbly_... # Settings > API keys
|
||||
export WARMBLY_API_URL=https://api.your-instance.com # omit for the hosted service
|
||||
|
||||
warmblyctl me # who the key is, and its scopes
|
||||
warmblyctl campaign list
|
||||
warmblyctl campaign start --id <uuid>
|
||||
```
|
||||
|
||||
Every command prints the API's JSON response to stdout, untouched, and exits 1 on failure with the API's own `code` and `request_id` on stderr. What a key can do is exactly its granted [permissions](/api/permissions/); `warmblyctl me` shows them.
|
||||
|
||||
### The families
|
||||
|
||||
| Family | Covers |
|
||||
|---|---|
|
||||
| `me` | Identity and granted scopes |
|
||||
| `campaign` | List, get, create, update, delete, sequence steps, sender pool, preflight, start, stop, test email, send log |
|
||||
| `contact` | Search, get, lookup by address, create, update, delete, notes, timeline, import, export, custom fields |
|
||||
| `mailbox` | List, get, update, disconnect, auth check, sync state, sending behaviour, verify, send, warmup start/pause/resume/stop/status |
|
||||
| `inbox` | List, count, thread, mark seen, reply, compose, agent drafts, scheduled sends |
|
||||
| `analytics` | Dashboard, deliverability, warmup, accounts, campaigns, usage, audit logs |
|
||||
| `settings` | Outreach and suppression settings |
|
||||
| `webhook` | Endpoints, secrets, deliveries, event types |
|
||||
| `apikey` | Key self-service: list, create, update, revoke, the scope catalog |
|
||||
| `template` | Reply templates |
|
||||
| `crm` | Pipelines, deals, tasks |
|
||||
|
||||
`warmblyctl <family> --help` lists a family's subcommands, and `warmblyctl <family> <subcommand> --help` prints its flags and the endpoint it calls.
|
||||
|
||||
### The raw passthrough
|
||||
|
||||
Anything the API can do that has no typed command yet is one `api` call away, so nothing is out of reach:
|
||||
|
||||
```bash
|
||||
warmblyctl api get "/campaigns?limit=10"
|
||||
warmblyctl api post /contacts --data '{"email":"jane@example.com"}'
|
||||
warmblyctl api patch "/campaigns/<id>" --data @changes.json
|
||||
warmblyctl api delete "/webhooks/<id>"
|
||||
```
|
||||
|
||||
Paths are relative to `/v1`. `--data` takes a JSON literal, `-` for stdin, or `@file`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- List responses are `{"data": [...], "pagination": {"next_cursor", "has_more"}}`. Page with `--cursor` until `has_more` is false; the cursor is opaque.
|
||||
- Writes accept `--idempotency-key`, and a retried command with the same key can never double-apply. Details on [authentication](/api/authentication/).
|
||||
- A `429` failure names its `Retry-After`; wait it out and retry.
|
||||
- `contact list` is a search: `--data` carries the filter body, and omitting it lists everything.
|
||||
|
||||
<Callout type="warn" title="Six commands put real mail on the wire">
|
||||
`campaign start`, `campaign test-email`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft` send. Everything else reads or edits drafts. Run `campaign preflight` before `campaign start`; it is free and catches missing senders, empty audiences and broken tracking.
|
||||
</Callout>
|
||||
|
||||
### For AI agents
|
||||
|
||||
The repository ships skills under `skills/` (`warmbly-api` for the product, `warmbly-ops` for instance administration) that teach a coding agent these commands, the conventions above, and the sending-safety rules. Install them the way your agent expects, for example `cp -r skills/warmbly-api ~/.claude/skills/` for Claude Code, or point the agent at the `SKILL.md` directly. An agent given a scoped key and those skills can operate a workspace end to end without touching the database.
|
||||
|
||||
## When Redis is down
|
||||
|
||||
Account recovery must not depend on the cache, so most commands treat an unreachable Redis as a warning and carry on.
|
||||
@@ -374,3 +441,4 @@ Every one of these runs `warmblyctl` inside the backend container of a compose i
|
||||
- [Troubleshooting](/development/troubleshooting/) for symptoms and the command that fixes each one
|
||||
- [Export and import](/guides/workspace-export-import/) for what an archive contains and what deliberately does not travel
|
||||
- [Configuration reference](/development/configuration/) for every variable named here
|
||||
- [API authentication](/api/authentication/) and [permissions](/api/permissions/) for the keys and scopes the API commands run on
|
||||
|
||||
@@ -39,7 +39,7 @@ require (
|
||||
github.com/stripe/stripe-go/v76 v76.25.0
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/api v0.260.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
@@ -184,7 +184,7 @@ require (
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kulti/thelper v0.6.3 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.10 // indirect
|
||||
@@ -210,9 +210,11 @@ require (
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/go-archive v0.3.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
@@ -252,7 +254,7 @@ require (
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.2 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sivchari/tenv v1.12.1 // indirect
|
||||
github.com/sonatard/noctx v0.1.0 // indirect
|
||||
@@ -311,13 +313,13 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
|
||||
@@ -554,8 +554,8 @@ github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3
|
||||
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -640,26 +640,26 @@ github.com/moby/buildkit v0.14.1 h1:2epLCZTkn4CikdImtsLtIa++7DzCimrrZCT1sway+oI=
|
||||
github.com/moby/buildkit v0.14.1/go.mod h1:1XssG7cAqv5Bz1xcGMxJL123iCv5TYN4Z/qf647gfuk=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c=
|
||||
github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
|
||||
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
|
||||
github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI=
|
||||
github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg=
|
||||
github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc=
|
||||
github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
@@ -810,8 +810,8 @@ github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5g
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY=
|
||||
@@ -1029,8 +1029,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
@@ -1054,8 +1054,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1077,8 +1077,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -1093,8 +1093,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1110,7 +1110,6 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1120,8 +1119,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
@@ -1130,8 +1129,8 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -1143,8 +1142,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -1169,8 +1168,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: warmbly-api
|
||||
description: Operate a running Warmbly instance (hosted or self-hosted) as an agent through the warmblyctl API commands - list/create/start campaigns, manage contacts and mailboxes, read and reply in the unified inbox, change settings, read analytics. Use whenever the task is to interact with Warmbly the product (campaigns, contacts, mailboxes, inbox, warmup, webhooks) rather than to change its source code.
|
||||
---
|
||||
|
||||
# Driving Warmbly through warmblyctl
|
||||
|
||||
`warmblyctl` speaks Warmbly's public REST API. Every API command prints the
|
||||
API's JSON response to stdout and exits 1 with a machine-readable error on
|
||||
failure, so it is safe to script against.
|
||||
|
||||
## Setup
|
||||
|
||||
Two environment variables:
|
||||
|
||||
```bash
|
||||
export WARMBLY_API_KEY=wmbly_... # required
|
||||
export WARMBLY_API_URL=https://api.your-instance.com # omit for the hosted service
|
||||
```
|
||||
|
||||
Keys are created in the dashboard under Settings > API keys, or with
|
||||
`warmblyctl apikey create` if you already hold a key with the API_KEYS scope.
|
||||
Everything you can do is bounded by the key's scopes; `warmblyctl me` shows
|
||||
who the key is and what it holds. On the local dev stack the seeded
|
||||
full-access key is `wmbly_seed_acme_owner_full_access_0000000000` with
|
||||
`WARMBLY_API_URL=http://localhost:8080`.
|
||||
|
||||
## Command map
|
||||
|
||||
Run `warmblyctl <family> --help` for subcommands and `warmblyctl <family>
|
||||
<sub> --help` for flags. Families:
|
||||
|
||||
| Family | Covers |
|
||||
|---|---|
|
||||
| `me` | Identity and granted scopes |
|
||||
| `campaign` | list, get, create, update, delete, steps, senders, preflight, start, stop, test-email, logs |
|
||||
| `contact` | list (search), get, lookup, create, update, delete, notes, timeline, import, export |
|
||||
| `mailbox` | list, get, update, delete, auth-check, sync, behavior, verify, send, warmup-start/pause/resume/stop/status |
|
||||
| `inbox` | list, count, thread, seen, reply, compose, agent drafts, scheduled sends |
|
||||
| `analytics` | dashboard, deliverability, warmup, accounts, campaigns, usage, audit-logs |
|
||||
| `settings` | outreach and suppression settings |
|
||||
| `webhook` | endpoints, secrets, deliveries, event types |
|
||||
| `apikey` | self-service key management |
|
||||
| `template` | reply templates |
|
||||
| `crm` | pipelines, deals, tasks |
|
||||
|
||||
Anything without a typed command is reachable through the raw passthrough:
|
||||
|
||||
```bash
|
||||
warmblyctl api get "/campaigns?limit=10"
|
||||
warmblyctl api post /contacts --data '{"email":"jane@example.com"}'
|
||||
warmblyctl api patch "/campaigns/<id>" --data @changes.json
|
||||
```
|
||||
|
||||
Paths are relative to `/v1`. Write bodies are JSON: a literal, `-` for stdin,
|
||||
or `@file`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Lists return `{"data": [...], "pagination": {"next_cursor", "has_more"}}`.
|
||||
Page with `--cursor <next_cursor>` until `has_more` is false. The cursor is
|
||||
opaque; never construct one.
|
||||
- Errors carry `code` and `request_id`. Branch on `code`
|
||||
(`not_found`, `forbidden`, `rate_limit_exceeded`, ...), quote `request_id`
|
||||
when reporting a failure.
|
||||
- On `rate_limit_exceeded` wait the `Retry-After` the error names, then retry.
|
||||
- Retried writes: pass `--idempotency-key <same-key>` so a retry can never
|
||||
double-apply. Any unique string works; reuse it only for the identical retry.
|
||||
- `contact list` is a search: `--data` carries the filter body, e.g.
|
||||
`--data '{"query":"acme.com"}'`. Omit it to list everything.
|
||||
|
||||
## Sending safety - read before anything that sends
|
||||
|
||||
These commands put real mail on the wire: `campaign start`,
|
||||
`campaign test-email`, `mailbox send`, `inbox reply`, `inbox compose`,
|
||||
`inbox approve-draft`. Everything else is safe to run freely.
|
||||
|
||||
- Run `campaign preflight --id <id>` before `campaign start` and act on what
|
||||
it reports. It costs nothing and catches missing senders, empty audiences
|
||||
and broken tracking.
|
||||
- Never raise a mailbox's daily cap casually. The platform default is 50
|
||||
campaign emails per mailbox per day with 600 seconds between sends; a fresh
|
||||
mailbox should start around 10-20. Do not set a cap above 50 unless the
|
||||
user explicitly asked for it and the mailbox has history to justify it.
|
||||
- Keep warmup running on mailboxes that campaign; do not stop warmup just
|
||||
because a campaign started.
|
||||
- If deliverability analytics show rising bounces or complaints, stop the
|
||||
campaign first and report; do not push volume into a degrading mailbox.
|
||||
|
||||
## Recovery and instance administration
|
||||
|
||||
Creating accounts, resetting passwords, granting admin, and instance health
|
||||
are the operator half of warmblyctl and need database access, not an API key.
|
||||
That is the `warmbly-ops` skill.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: warmbly-ops
|
||||
description: Administer and recover a self-hosted Warmbly instance with warmblyctl's operator commands - instance status and health checks, creating accounts, resetting passwords, granting or revoking platform admin, disabling 2FA, claiming a fresh instance, exporting or importing a workspace. Use when the task is about the instance itself (accounts, access, health, migration) rather than campaigns or contacts.
|
||||
---
|
||||
|
||||
# Operating a Warmbly instance with warmblyctl
|
||||
|
||||
The operator commands talk to the database directly, so they work when
|
||||
signing in does not. They run wherever `PRIMARY_DB` is set, which in practice
|
||||
means inside the backend container:
|
||||
|
||||
```bash
|
||||
docker compose -p warmbly exec backend warmblyctl <command>
|
||||
make cli ARGS="<command>" # same thing on a compose install
|
||||
```
|
||||
|
||||
## Start every investigation with status
|
||||
|
||||
```bash
|
||||
docker compose -p warmbly exec backend warmblyctl status # prose + exit 1 on errors
|
||||
docker compose -p warmbly exec backend warmblyctl status --json # stable keys, always exits 0
|
||||
make doctor # the same, wrapped
|
||||
```
|
||||
|
||||
`--json` keys are a contract (only ever appended to); read `.summary.error`
|
||||
for the verdict and `.next_steps` for the exact commands the situation calls
|
||||
for. Prefer parsing that over reasoning from the prose.
|
||||
|
||||
## The commands
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Claim a fresh instance | `warmblyctl setup-link` (refuses once accounts exist) |
|
||||
| Create an account | `warmblyctl user create --email you@example.com [--admin]` |
|
||||
| List accounts / find admins | `warmblyctl user list [--admin]` |
|
||||
| Reset a password | `warmblyctl user reset-password --email ...` (prints a link) |
|
||||
| Grant platform admin | `warmblyctl user grant-admin --email ... --role super\|support\|ops\|analyst` |
|
||||
| Revoke platform admin | `warmblyctl user revoke-admin --email ...` |
|
||||
| Clear a lost authenticator | `warmblyctl user disable-2fa --email ...` |
|
||||
| Hash for unattended bootstrap | `warmblyctl hash-password` |
|
||||
| List workspaces | `warmblyctl org list` |
|
||||
| Move a workspace out | `warmblyctl org export --org <id\|slug\|owner-email> --out file.zip` |
|
||||
| Move a workspace in | `warmblyctl org import --org ... --file file.zip --dry-run` first |
|
||||
|
||||
## Mechanics that bite
|
||||
|
||||
- Prompts need a TTY; pipes need `-T`. `docker compose exec` allocates a TTY
|
||||
unless you pass `-T`, so:
|
||||
- prompting: `docker compose -p warmbly exec backend warmblyctl user create --email ...`
|
||||
- piping: `printf '%s' "$PW" | docker compose -p warmbly exec -T backend warmblyctl user create --email ... --password-stdin`
|
||||
- Passwords are 8-128 characters, the dashboard's own rule.
|
||||
- `--admin` opens the admin panel on `ADMIN_URL` (port 5174 by default), not
|
||||
the dashboard on `APP_URL`.
|
||||
- Redis down: `setup-link` and link-minting `reset-password` fail; use
|
||||
`user reset-password --password-stdin` instead. Everything else degrades to
|
||||
a warning and keeps working.
|
||||
- `org import` always with `--dry-run` first; the report is free and names
|
||||
every member whose rows would be reassigned to the owner.
|
||||
- `org export --with-credentials` produces the most sensitive file the
|
||||
product has (every mailbox password and refresh token, sealed only by the
|
||||
passphrase you supply). Never write it anywhere world-readable, and never
|
||||
echo the passphrase.
|
||||
|
||||
## Interacting with the product itself
|
||||
|
||||
Campaigns, contacts, mailboxes, the inbox and settings go through the API
|
||||
half of warmblyctl with an API key. That is the `warmbly-api` skill.
|
||||
Reference in New Issue
Block a user