diff --git a/README.md b/README.md
index 389cf6b..b13524d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,14 @@
# csax
+
+
+[](https://pkg.go.dev/github.com/crydensync/cryden/v2)
+[](https://opensource.org/licenses/MIT)
+[](https://github.com/crydensync/csax/stargazers)
+[](https://github.com/crydensync/csax/network/members)
+
+
+
Admin CLI for CrydenSync — manage users, sessions, and audit logs from the terminal. Not end-user facing; this is for developers/operators running a CrydenSync-backed app, same as `psql` is for a database, not for the app's own users.
## Install
@@ -7,7 +16,24 @@ Admin CLI for CrydenSync — manage users, sessions, and audit logs from the ter
```bash
go install github.com/crydensync/csax@latest
```
+If you get `command not found` (or, on Windows, `'csax' is not recognized`) after this, `csax` installed correctly — it's just not on your `PATH` yet. Fix:
+
+**Linux / macOS / Termux:**
+```bash
+echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc # use ~/.zshrc if that's your shell
+source ~/.bashrc
+```
+
+**Windows (PowerShell):**
+```powershell
+setx PATH "$env:Path;$(go env GOPATH)\bin"
+```
+Then open a new terminal window for it to take effect.
+Confirm it worked:
+```bash
+csax version
+```
## Setup
```bash
@@ -19,11 +45,19 @@ Prompts for your database connection string and JWT secret, writes `.env`. Same
## Commands
```bash
-csax migrate up|down|status # run/track CrydenSync + your app's own migrations
+csax login # API-client mode only — logs in to a deployed `api` instance, caches the session
+csax logout # clears the cached session — local only, no network call
+csax migrate up|down|status # run/track CrydenSync + your app's own migrations — ALWAYS direct-DB, even in API-client mode
csax users list [--limit N] [--offset N] [--json] # every user, newest first
csax users get [--json] # user details, lock status, active session count
-csax users create # create a user directly
-csax users unlock # clear a lockout early
+csax users create # create a user directly — direct-DB only, no `api` equivalent exists
+csax users unlock # clear a lockout early — direct-DB only, no `api` equivalent exists
+csax digest [--since ] [--json] # plain-English weekly activity summary
+csax digest history [--limit N] [--json] # API-client mode only — past scheduled digests
+csax support diagnose [--json] # why can't this user log in, from what's already recorded
+csax config-tuning [--window-days N] [--json] # suggested config changes — never applied automatically, see below
+csax anomalies list [--status S] [--limit N] [--json] # API-client mode only — the flagged-event review queue
+csax anomalies review --status S [--note "..."] # API-client mode only — record a judgement, never an action
csax sessions list --user [--json]
csax sessions revoke --user
csax sessions revoke-all --user
@@ -47,6 +81,50 @@ csax health
csax version
```
+`ai query`/`logs`/`anomalies scan`/`ask`/`audit`, `users create`/`unlock`, and `sessions revoke`/`revoke-all` are always direct-DB, in both modes — see "API-client mode" below for exactly why.
+
+## Optional: API-client mode
+
+By default csax connects straight to Postgres and builds its own
+`cryden.Engine` in-process — this is "direct-DB mode," and it's
+everything described above with no special setup. If you'd rather an
+operator not need `DATABASE_URL` in their hands at all, set
+`CSAX_API_URL` to a running `api` deployment instead:
+
+```
+CSAX_API_URL=https://api.example.com
+CSAX_API_EMAIL=you@example.com # optional — prompted if unset
+CSAX_API_PASSWORD=... # optional — prompted, hidden, if unset. Don't put a real password in .env.
+```
+
+Then `csax login` once. The session is cached to `~/.csax/session.json`
+(mode `0600`, never the password) and refreshed transparently after
+that — later commands don't ask for credentials again until the
+cached session is actually invalid. `csax logout` clears it; this is
+purely local, it doesn't call the deployment at all.
+
+**A real, permanent split, not a temporary gap:** `users list`/`get`,
+`oauth providers list`/`test`, `health`, `digest`, `support diagnose`,
+and `config-tuning` all work in API-client mode. `users create`/
+`unlock`, `sessions revoke`/`revoke-all`, and every `ai` command do
+not, and never will in this mode — `api`'s own admin console was built
+deliberately without account-lock/unlock power, cross-user session
+revocation, or an unscoped natural-language query surface. Those
+commands stay direct-DB-only so that capability isn't quietly routed
+around; if it's ever wanted over HTTP, that's a decision for `api`'s
+own project to make on purpose, not something this CLI should do for
+it. `digest history` and `anomalies list`/`review` are the reverse
+case: API-client mode only, because what they read
+(`reviewed_anomalies`, scheduled digest history) is `api`'s own table,
+not part of `cryden`'s schema at all.
+
+**If the deployment runs on SQLite:** every `/v1/admin/*` route on
+`api` answers `501` unconditionally — the whole admin console is
+Postgres-only there, not just parts of it. `csax login` and
+`csax health` still work fine (they're not admin routes), but every
+other API-client command will fail with a clear message rather than a
+raw HTTP status.
+
## Optional: OAuth admin commands
`oauth providers list`/`add`/`test` all read the SAME env vars `api`
@@ -131,9 +209,18 @@ requirements.
- Colored, box-drawing table output by default (`table.go`) — auto-disabled when not writing to a real terminal, or when `NO_COLOR` is set (see https://no-color.org). Long values (UUIDs, emails) are truncated with `…` rather than blowing up column widths. Audit event types get semantic color: red for anything `*_failed`/`*_reuse_detected`/`*_locked`, green for `*_success`/`*_linked`/`*_unlocked`.
- A terminal spinner (`spinner.go`) shows during any real network/DB round trip — AI provider calls, the OAuth endpoint reachability check, and the read-only role's connection test. Skipped automatically in the same non-TTY/`NO_COLOR` cases as the table output, so scripted/piped usage never sees spinner frames mixed into captured output.
- `csax doctor` aggregates the existing `csax health` and `csax ai audit` checks plus an OAuth config summary into one command — it calls the same underlying functions those commands use rather than re-implementing the checks a second time.
-- **Known gap, not addressed this release:** `--json` is only implemented on a handful of commands (`users get`, `users list`, `oauth providers list`, `oauth users get`, `ai query`). Retrofitting it consistently across every command (`ai logs`, `ai ask`, `ai anomalies scan`, `ai audit`, `doctor`, `stats`, `audit tail`/`search`) is real, not-yet-done work — each needs its own JSON-serializable shape, not just a flag.
+- API-client mode (`apiclient.go`) is a minimal `net/http`-based client — no request library, same dependency-light philosophy as everything else here. It's additive: every command's direct-DB path is unchanged, `CSAX_API_URL` just gives supported commands a second way to run. `DATABASE_URL` is accordingly only required in direct-DB mode now — requiring it unconditionally would have defeated the entire point of a mode that exists so an operator doesn't need a database credential at all. `csax migrate` is the one exception, checked explicitly, since there's no sane way to run a schema migration through an HTTP admin API.
+- `migrations/` now has all seven of cryden's current Postgres migrations (`0001` through `0007`), copied verbatim, plus the `0001_initial_schema.down.sql` this repo was previously missing entirely.
+- **Known gap, not addressed this release:** `--json` is implemented on `users get`/`list`, `oauth providers list`, `oauth users get`, `ai query`, `digest`, `digest history`, `support diagnose`, `config-tuning`, and `anomalies list`. Still missing on `ai logs`, `ai ask`, `ai anomalies scan`, `ai audit`, `doctor`, `stats`, `audit tail`/`search`, and `sessions list` — each needs its own JSON-serializable shape, not just a flag.
+- **Known gap, not addressed this release:** `oauthProviders()` in `cmd_oauth.go` still only lists `google`/`github` — `api` has supported `microsoft`/`discord`/`gitlab`/`apple` since its own Tier 1. `oauth providers list`/`test`/`add` in direct-DB mode won't show or configure the other four until this list is updated; API-client mode's version (reading `api`'s own oauth health endpoint) already reflects whatever `api` actually has configured, independent of this gap.
- **Known gap, not addressed this release:** this is still one flat `package main` across ~20 files. Splitting into proper subpackages (`internal/oauth`, `internal/ai`, `internal/config`, ...) is a real structural change touching nearly every file's imports at once — deliberately NOT attempted alongside this release's feature work, since it's a much higher-risk change to make at the same time as everything else here, and much harder to review as one large diff. Worth doing as its own dedicated pass, with nothing else changing in that same commit.
## License
-MIT
+MIT, see [LICENSE](./LICENSE).
+
+---
+
+
+ Built with ❤️ in Africa · Own your users, not vendor lock-in
+
diff --git a/apiclient.go b/apiclient.go
new file mode 100644
index 0000000..1f8aea7
--- /dev/null
+++ b/apiclient.go
@@ -0,0 +1,397 @@
+package main
+
+// This file is csax's second way to reach a deployment: calling a
+// running `api` instance's admin endpoints over HTTP, authenticated
+// as an operator, instead of touching Postgres directly. It exists
+// alongside connectDB/buildEngine in config.go, not instead of them —
+// see apiMode below for how a command picks which one applies.
+//
+// The commands with no matching api endpoint (users create/unlock,
+// sessions revoke/revoke-all, ai query/logs/audit) are not touched by
+// this file at all and keep working exactly as before regardless of
+// whether CSAX_API_URL is set — api's own admin console was built
+// deliberately without account-lock/unlock or cross-user session
+// revocation power, and closing that gap is a decision for api's own
+// project, not something this repo routes around.
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/term"
+)
+
+// apiMode reports whether a command should reach the deployment over
+// HTTP instead of connecting to Postgres directly.
+func apiMode(cfg csaxConfig) bool {
+ return cfg.APIURL != ""
+}
+
+// sessionCache is what's persisted to disk between commands, so an
+// operator isn't asked to log in again for every single command.
+// Never holds the password — only tokens.
+type sessionCache struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func sessionCachePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("finding home directory for session cache: %w", err)
+ }
+ return filepath.Join(home, ".csax", "session.json"), nil
+}
+
+func loadSessionCache() (*sessionCache, error) {
+ path, err := sessionCachePath()
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("reading cached session: %w", err)
+ }
+ var sc sessionCache
+ if err := json.Unmarshal(data, &sc); err != nil {
+ // A corrupt cache file should not block login — start fresh
+ // rather than failing every command until a human intervenes.
+ return nil, nil
+ }
+ return &sc, nil
+}
+
+func saveSessionCache(sc *sessionCache) error {
+ path, err := sessionCachePath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return fmt.Errorf("creating session cache directory: %w", err)
+ }
+ data, err := json.Marshal(sc)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0600)
+}
+
+func clearSessionCache() error {
+ path, err := sessionCachePath()
+ if err != nil {
+ return err
+ }
+ err = os.Remove(path)
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+// apiEnvelope mirrors api's own {"data":...}/{"error":...} response
+// shape exactly — see crydensync-web's api/index.mdx.
+type apiEnvelope struct {
+ Data json.RawMessage `json:"data"`
+ Error *apiErrorBody `json:"error"`
+}
+
+type apiErrorBody struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+// apiCallError is what every apiClient method returns for a non-2xx
+// response — the code is preserved so callers (and errorHint below)
+// can branch on it, matching how api's own docs tell a client to
+// handle its errors: on code, never on message text.
+type apiCallError struct {
+ StatusCode int
+ Code string
+ Message string
+}
+
+func (e *apiCallError) Error() string {
+ if hint := errorHint(e.Code); hint != "" {
+ return fmt.Sprintf("%s (%s: %s)", e.Message, e.Code, hint)
+ }
+ return fmt.Sprintf("%s (%s)", e.Message, e.Code)
+}
+
+// errorHint expands the handful of codes an operator needs plain
+// English for, not a bare status dump.
+func errorHint(code string) string {
+ switch code {
+ case "not_operator":
+ return "this account is not an admin on this deployment — grant it with the api repo's own grant-operator tool, then log in again"
+ case "not_implemented_on_sqlite":
+ return "this deployment runs on SQLite — the admin console isn't available at all there yet, core auth (login/health) still works, but no /v1/admin/* route does"
+ default:
+ return ""
+ }
+}
+
+// apiClient wraps calls to a deployed api instance's admin surface,
+// authenticated as an operator.
+type apiClient struct {
+ baseURL string
+ http *http.Client
+ session *sessionCache
+}
+
+func newAPIClient(cfg csaxConfig) (*apiClient, error) {
+ if cfg.APIURL == "" {
+ return nil, fmt.Errorf("CSAX_API_URL is not set")
+ }
+ sc, err := loadSessionCache()
+ if err != nil {
+ return nil, err
+ }
+ return &apiClient{
+ baseURL: strings.TrimRight(cfg.APIURL, "/"),
+ http: &http.Client{Timeout: 15 * time.Second},
+ session: sc,
+ }, nil
+}
+
+// ensureLoggedIn returns a valid access token: the cached one if it's
+// still fresh, a refreshed one if not, or a brand new login if there's
+// no cached session (or refreshing it failed) at all.
+func (c *apiClient) ensureLoggedIn(cfg csaxConfig) (string, error) {
+ if c.session != nil && time.Now().Before(c.session.ExpiresAt) {
+ return c.session.AccessToken, nil
+ }
+ if c.session != nil && c.session.RefreshToken != "" {
+ if tok, err := c.refresh(); err == nil {
+ return tok, nil
+ }
+ // The refresh token itself may have been rotated away or
+ // revoked server-side — fall through to a fresh login rather
+ // than failing here.
+ }
+ return c.login(cfg)
+}
+
+func promptLine(label string) string {
+ fmt.Print(label)
+ reader := bufio.NewReader(os.Stdin)
+ line, _ := reader.ReadString('\n')
+ return strings.TrimSpace(line)
+}
+
+func promptPassword(label string) (string, error) {
+ fmt.Print(label)
+ pw, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Println()
+ if err != nil {
+ return "", fmt.Errorf("reading password: %w", err)
+ }
+ return string(pw), nil
+}
+
+func (c *apiClient) login(cfg csaxConfig) (string, error) {
+ email := cfg.APIEmail
+ if email == "" {
+ email = promptLine("Operator email: ")
+ }
+ password := cfg.APIPassword
+ if password == "" {
+ var err error
+ password, err = promptPassword("Operator password: ")
+ if err != nil {
+ return "", err
+ }
+ }
+
+ var loginResp struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ SecondFactorRequired bool `json:"second_factor_required"`
+ PendingToken string `json:"pending_token"`
+ Methods []string `json:"methods"`
+ }
+ if err := c.rawPost("/v1/login", map[string]string{"email": email, "password": password}, &loginResp); err != nil {
+ return "", err
+ }
+
+ if loginResp.SecondFactorRequired {
+ return c.completeTOTPLogin(loginResp.PendingToken, loginResp.Methods)
+ }
+ return c.storeSession(loginResp.AccessToken, loginResp.RefreshToken)
+}
+
+// completeTOTPLogin handles the one second factor a terminal prompt
+// can reasonably complete. An operator account enrolled only in
+// passkeys or recovery codes can't finish logging in through csax at
+// all yet — that's said plainly rather than csax hanging or failing
+// with an unrelated-looking error.
+func (c *apiClient) completeTOTPLogin(pendingToken string, methods []string) (string, error) {
+ hasTOTP := false
+ for _, m := range methods {
+ if m == "totp" {
+ hasTOTP = true
+ }
+ }
+ if !hasTOTP {
+ return "", fmt.Errorf("this operator account requires a second factor (%s) csax cannot complete from a terminal — TOTP is the only one supported here", strings.Join(methods, ", "))
+ }
+
+ code := promptLine("TOTP code: ")
+
+ var tokens struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := c.rawPost("/v1/login/totp", map[string]string{"pending_token": pendingToken, "code": code}, &tokens); err != nil {
+ return "", err
+ }
+ return c.storeSession(tokens.AccessToken, tokens.RefreshToken)
+}
+
+func (c *apiClient) refresh() (string, error) {
+ if c.session == nil || c.session.RefreshToken == "" {
+ return "", fmt.Errorf("no cached refresh token")
+ }
+ var tokens struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := c.rawPost("/v1/refresh", map[string]string{"refresh_token": c.session.RefreshToken}, &tokens); err != nil {
+ return "", err
+ }
+ return c.storeSession(tokens.AccessToken, tokens.RefreshToken)
+}
+
+func (c *apiClient) storeSession(accessToken, refreshToken string) (string, error) {
+ // Access tokens are short-lived JWTs (15 minutes by default); csax
+ // treats one as good for 10 to leave margin for clock drift and
+ // request latency rather than parsing the JWT's own exp claim.
+ c.session = &sessionCache{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ }
+ if err := saveSessionCache(c.session); err != nil {
+ return "", fmt.Errorf("caching session: %w", err)
+ }
+ return accessToken, nil
+}
+
+// rawPost is used only by login/refresh/TOTP-completion, which run
+// before any access token exists — every other call goes through do,
+// below, which attaches one.
+func (c *apiClient) rawPost(path string, body any, out any) error {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ resp, err := c.http.Post(c.baseURL+path, "application/json", bytes.NewReader(b))
+ if err != nil {
+ return fmt.Errorf("contacting api: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var env apiEnvelope
+ if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+ return fmt.Errorf("decoding api response: %w", err)
+ }
+ if env.Error != nil {
+ return &apiCallError{StatusCode: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message}
+ }
+ if out != nil {
+ if err := json.Unmarshal(env.Data, out); err != nil {
+ return fmt.Errorf("decoding api data: %w", err)
+ }
+ }
+ return nil
+}
+
+// do is what every admin command in later tiers calls. It attaches
+// the bearer token, retries exactly once after a transparent refresh
+// on a 401, and decodes the envelope.
+func (c *apiClient) do(cfg csaxConfig, method, path string, body any, out any) error {
+ token, err := c.ensureLoggedIn(cfg)
+ if err != nil {
+ return err
+ }
+
+ send := func(tok string) (*http.Response, error) {
+ var reader io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reader = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, c.baseURL+path, reader)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Authorization", "Bearer "+tok)
+ return c.http.Do(req)
+ }
+
+ resp, err := send(token)
+ if err != nil {
+ return fmt.Errorf("contacting api: %w", err)
+ }
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ // The cached access token may have expired between
+ // ensureLoggedIn's check above and this request landing —
+ // refresh once and retry rather than surfacing a stale-token
+ // error an operator can't do anything about.
+ resp.Body.Close()
+ newTok, rerr := c.refresh()
+ if rerr != nil {
+ return fmt.Errorf("session expired and refresh failed, run `csax login` again: %w", rerr)
+ }
+ resp, err = send(newTok)
+ if err != nil {
+ return fmt.Errorf("contacting api: %w", err)
+ }
+ }
+ defer resp.Body.Close()
+
+ var env apiEnvelope
+ if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+ return fmt.Errorf("decoding api response: %w", err)
+ }
+ if env.Error != nil {
+ return &apiCallError{StatusCode: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message}
+ }
+ if out != nil {
+ if err := json.Unmarshal(env.Data, out); err != nil {
+ return fmt.Errorf("decoding api data: %w", err)
+ }
+ }
+ return nil
+}
+
+func (c *apiClient) get(cfg csaxConfig, path string, out any) error {
+ return c.do(cfg, http.MethodGet, path, nil, out)
+}
+func (c *apiClient) post(cfg csaxConfig, path string, body, out any) error {
+ return c.do(cfg, http.MethodPost, path, body, out)
+}
+func (c *apiClient) put(cfg csaxConfig, path string, body, out any) error {
+ return c.do(cfg, http.MethodPut, path, body, out)
+}
+func (c *apiClient) delete(cfg csaxConfig, path string, out any) error {
+ return c.do(cfg, http.MethodDelete, path, nil, out)
+}
diff --git a/cmd_anomalies.go b/cmd_anomalies.go
new file mode 100644
index 0000000..084ec42
--- /dev/null
+++ b/cmd_anomalies.go
@@ -0,0 +1,107 @@
+package main
+
+// API-client mode only, and permanently so — unlike digest/support/
+// config-tuning, this isn't a matter of not having gotten to a
+// direct-DB path yet. The review-status table this reads
+// (reviewed_anomalies) is api's own addition to its own Postgres
+// schema, not part of cryden's schema at all. A direct-DB
+// implementation here would mean csax reaching into a table that
+// belongs to a different repository's migrations — exactly the kind
+// of cross-repository coupling this whole project has avoided
+// everywhere else. See CLAUDE.md.
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+func cmdAnomaliesList(cfg csaxConfig, status string, limit int, jsonOutput bool) {
+ if !apiMode(cfg) {
+ fmt.Println("`csax anomalies` requires API-client mode — set CSAX_API_URL. The review queue is api's own table, not part of cryden's schema, so there is no direct-DB path for this command at all.")
+ os.Exit(1)
+ }
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+
+ var entries []struct {
+ EventID string `json:"event_id"`
+ Type string `json:"type"`
+ UserID string `json:"user_id"`
+ Status string `json:"status"`
+ Note string `json:"note,omitempty"`
+ CreatedAt string `json:"created_at"`
+ }
+ path := fmt.Sprintf("/admin/anomalies?limit=%d", limit)
+ if status != "" {
+ path += "&status=" + status
+ }
+ if err := client.get(cfg, path, &entries); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ if jsonOutput {
+ b, _ := json.MarshalIndent(entries, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+ if len(entries) == 0 {
+ fmt.Println(dim("No flagged events found for this filter."))
+ return
+ }
+ columns := []string{"EVENT ID", "TYPE", "USER", "STATUS", "CREATED"}
+ var rows [][]string
+ for _, e := range entries {
+ rows = append(rows, []string{e.EventID, e.Type, e.UserID, anomalyStatusColor(e.Status), e.CreatedAt})
+ }
+ printTable(columns, rows)
+}
+
+func anomalyStatusColor(status string) string {
+ switch status {
+ case "confirmed":
+ return red(status)
+ case "dismissed":
+ return dim(status)
+ default:
+ return yellow(status)
+ }
+}
+
+// cmdAnomaliesReview records an operator's own judgement about one
+// already-flagged event. It takes no action on the account itself —
+// no lock, no revoke — there's nothing here to trigger, which is what
+// keeps this inside the read-only rule rather than being an exception
+// to it in spirit. See api's own admin.mdx for the same reasoning.
+func cmdAnomaliesReview(cfg csaxConfig, eventID, status, note string) {
+ if !apiMode(cfg) {
+ fmt.Println("`csax anomalies` requires API-client mode — set CSAX_API_URL.")
+ os.Exit(1)
+ }
+ if status != "unreviewed" && status != "confirmed" && status != "dismissed" {
+ fmt.Println(red("invalid status: must be unreviewed, confirmed, or dismissed"))
+ os.Exit(1)
+ }
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+
+ body := map[string]string{"status": status}
+ if note != "" {
+ body["note"] = note
+ }
+ var resp struct {
+ Status string `json:"status"`
+ }
+ if err := client.put(cfg, "/admin/anomalies/"+eventID, body, &resp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+ fmt.Printf("Recorded: %s is now %s\n", eventID, anomalyStatusColor(resp.Status))
+}
diff --git a/cmd_digest_support_tuning.go b/cmd_digest_support_tuning.go
new file mode 100644
index 0000000..8d266f3
--- /dev/null
+++ b/cmd_digest_support_tuning.go
@@ -0,0 +1,199 @@
+package main
+
+// New surface, not an extension of an existing command — cryden's
+// weekly digest, login diagnosis, and config tuning advisor had no
+// csax command at all before this. Both modes make sense here, unlike
+// most of this PR's other additions: cryden.WeeklyDigest/DigestSince,
+// cryden.DiagnoseLoginIssue, and cryden.ConfigTuningReport/
+// TuningReportSince are plain engine functions taking nothing but the
+// *cryden.Engine direct-DB mode already builds for other commands, so
+// there's no reason to make these API-client-only the way the
+// anomaly review queue in Tier 7 has to be.
+//
+// digest history has no direct-DB equivalent at all — cryden's own
+// WeeklyDigest only ever answers on demand, there is no engine-level
+// concept of scheduled history to read locally. That one sub-command
+// is API-client-only, and says so plainly rather than doing nothing.
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/crydensync/cryden/v2"
+)
+
+func cmdDigest(cfg csaxConfig, db *sql.DB, sinceDuration time.Duration, jsonOutput bool) {
+ engine := mustBuildEngine(cfg, db)
+ ctx := context.Background()
+
+ var text string
+ var err error
+ if sinceDuration > 0 {
+ text, err = cryden.DigestSince(ctx, engine, time.Now().Add(-sinceDuration))
+ } else {
+ text, err = cryden.WeeklyDigest(ctx, engine)
+ }
+ if err != nil {
+ fmt.Println(red("failed to build digest: " + err.Error()))
+ os.Exit(1)
+ }
+ printReportText(text, jsonOutput)
+}
+
+func cmdDigestAPI(cfg csaxConfig, sinceDays int, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+ var resp struct {
+ Text string `json:"text"`
+ }
+ path := "/admin/digest"
+ if sinceDays > 0 {
+ path = fmt.Sprintf("/admin/digest?window_days=%d", sinceDays)
+ }
+ if err := client.get(cfg, path, &resp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+ printReportText(resp.Text, jsonOutput)
+}
+
+// cmdDigestHistoryAPI has no direct-DB counterpart at all — see this
+// file's own top-of-file comment for why. Callers reach this only
+// through API-client mode; main.go refuses it outright otherwise.
+func cmdDigestHistoryAPI(cfg csaxConfig, limit int, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+ var entries []struct {
+ Text string `json:"text"`
+ CreatedAt string `json:"created_at"`
+ }
+ if err := client.get(cfg, fmt.Sprintf("/admin/digest/history?limit=%d", limit), &entries); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+ if jsonOutput {
+ b, _ := json.MarshalIndent(entries, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+ if len(entries) == 0 {
+ fmt.Println(dim("No digest history recorded yet — this deployment may not have a scheduled digest job configured."))
+ return
+ }
+ for i, e := range entries {
+ if i > 0 {
+ fmt.Println(dim("\n---"))
+ }
+ fmt.Println(dim(e.CreatedAt))
+ fmt.Println(e.Text)
+ }
+}
+
+func cmdSupportDiagnose(cfg csaxConfig, db *sql.DB, email string, jsonOutput bool) {
+ engine := mustBuildEngine(cfg, db)
+ text, err := cryden.DiagnoseLoginIssue(context.Background(), engine, email)
+ if err != nil {
+ fmt.Println(red("diagnosis failed: " + err.Error()))
+ os.Exit(1)
+ }
+ printReportText(text, jsonOutput)
+}
+
+func cmdSupportDiagnoseAPI(cfg csaxConfig, email string, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+ var resp struct {
+ Text string `json:"text"`
+ }
+ if err := client.get(cfg, "/admin/support/diagnose?email="+email, &resp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+ printReportText(resp.Text, jsonOutput)
+}
+
+func cmdConfigTuning(cfg csaxConfig, db *sql.DB, windowDays int, jsonOutput bool) {
+ engine := mustBuildEngine(cfg, db)
+ ctx := context.Background()
+
+ var text string
+ var err error
+ if windowDays > 0 {
+ text, err = cryden.TuningReportSince(ctx, engine, time.Now().Add(-time.Duration(windowDays)*24*time.Hour))
+ } else {
+ text, err = cryden.ConfigTuningReport(ctx, engine)
+ }
+ if err != nil {
+ fmt.Println(red("failed to build config tuning report: " + err.Error()))
+ os.Exit(1)
+ }
+ printReportText(text, jsonOutput)
+}
+
+// cmdConfigTuningAPI renders api's structured suggestion list the
+// same way the direct-DB text report reads, so the two modes don't
+// look like different tools — see api's own admin.mdx: this endpoint
+// returns {area, finding, suggestion} triples, not pre-rendered text.
+func cmdConfigTuningAPI(cfg csaxConfig, windowDays int, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+ var resp struct {
+ Suggestions []struct {
+ Area string `json:"area"`
+ Finding string `json:"finding"`
+ Suggestion string `json:"suggestion"`
+ } `json:"suggestions"`
+ }
+ path := "/admin/config-tuning"
+ if windowDays > 0 {
+ path = fmt.Sprintf("/admin/config-tuning?window_days=%d", windowDays)
+ }
+ if err := client.get(cfg, path, &resp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ if jsonOutput {
+ b, _ := json.MarshalIndent(resp.Suggestions, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+ if len(resp.Suggestions) == 0 {
+ fmt.Println(dim("Nothing stands out against current settings in this window."))
+ return
+ }
+ for _, s := range resp.Suggestions {
+ fmt.Println(yellow(s.Area))
+ fmt.Println(" " + s.Finding)
+ fmt.Println(" → " + s.Suggestion)
+ fmt.Println()
+ }
+}
+
+// printReportText is shared by every command in this file that just
+// gets back one block of prose — the only thing that differs between
+// them is where the text came from.
+func printReportText(text string, jsonOutput bool) {
+ if jsonOutput {
+ b, _ := json.Marshal(map[string]string{"text": text})
+ fmt.Println(string(b))
+ return
+ }
+ fmt.Println(text)
+}
diff --git a/cmd_health.go b/cmd_health.go
index fc0b6cf..00be6ef 100644
--- a/cmd_health.go
+++ b/cmd_health.go
@@ -3,10 +3,11 @@ package main
import (
"database/sql"
"fmt"
+ "os"
)
const csaxVersion = "dev" // set to the real tag (e.g. "v1.1.0") when this release is actually cut
-const targetCrydenVersion = "cryden/v2 v2.1.0"
+const targetCrydenVersion = "cryden/v2 v2.5.0"
func cmdHealth(db *sql.DB) {
if err := db.Ping(); err != nil {
@@ -24,6 +25,32 @@ func cmdHealth(db *sql.DB) {
fmt.Println("✔ Migration tracking present")
}
+func cmdHealthAPI(cfg csaxConfig) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+ // /v1/health is public — deliberately not routed through
+ // client.get, which would try to log in first. A health check
+ // should work even before an operator has ever authenticated,
+ // the same way it works with no database credential in direct-DB
+ // mode either.
+ resp, err := client.http.Get(client.baseURL + "/v1/health")
+ if err != nil {
+ fmt.Println(red("✗ api unreachable: " + err.Error()))
+ os.Exit(1)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 200 {
+ fmt.Println(green("✔") + " api reachable at " + cfg.APIURL)
+ } else {
+ fmt.Println(red(fmt.Sprintf("✗ api answered %d — database is likely unreachable from the api side", resp.StatusCode)))
+ os.Exit(1)
+ }
+}
+
func cmdVersion() {
fmt.Printf("csax %s (%s)\n", csaxVersion, targetCrydenVersion)
}
diff --git a/cmd_login.go b/cmd_login.go
new file mode 100644
index 0000000..2370201
--- /dev/null
+++ b/cmd_login.go
@@ -0,0 +1,86 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// cmdLogin logs in to a deployed api instance as an operator and
+// caches the session, so later commands (Tier 3 onward) don't ask for
+// credentials again. This command's whole job in this tier is proving
+// the round trip end to end — it deliberately does nothing else yet.
+func cmdLogin(cfg csaxConfig) {
+ if !apiMode(cfg) {
+ fmt.Fprintln(os.Stderr, "CSAX_API_URL is not set — `csax login` only applies to API-client mode. Direct-DB mode (the default) needs no login at all.")
+ os.Exit(1)
+ }
+
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ os.Exit(1)
+ }
+
+ token, err := client.ensureLoggedIn(cfg)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "login failed:", err)
+ os.Exit(1)
+ }
+
+ // Confirm the token actually round-trips against the deployment,
+ // not just that login itself returned something.
+ var verifyResp struct {
+ UserID string `json:"user_id"`
+ }
+ if err := client.get(cfg, "/v1/verify", &verifyResp); err != nil {
+ fmt.Fprintln(os.Stderr, "logged in, but the token failed to verify:", err)
+ os.Exit(1)
+ }
+
+ role := roleFromToken(token)
+ fmt.Printf("Logged in to %s as %s\n", cfg.APIURL, verifyResp.UserID)
+ if role == "" {
+ fmt.Println("This account has no admin role — API-client mode's admin commands (Tier 3 onward) will fail with not_operator until one is granted.")
+ } else {
+ fmt.Printf("Role: %s\n", role)
+ }
+}
+
+// roleFromToken reads the "role" claim out of the access token's own
+// JWT payload for display purposes only — this is not a trust
+// decision. The server verifies the token's signature and the role
+// claim on every real admin request regardless of what csax shows
+// here; a tampered local display value can't grant anything.
+func roleFromToken(accessToken string) string {
+ parts := strings.Split(accessToken, ".")
+ if len(parts) != 3 {
+ return ""
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return ""
+ }
+ var claims struct {
+ Role string `json:"role"`
+ }
+ if err := json.Unmarshal(payload, &claims); err != nil {
+ return ""
+ }
+ return claims.Role
+}
+
+// cmdLogout clears the cached session — csax's equivalent of the
+// deployment's own /v1/logout, but purely local: it does not call the
+// deployment at all, since a cached refresh token an operator wants
+// gone should be forgotten immediately, not on a best-effort network
+// call that could fail.
+func cmdLogout() {
+ if err := clearSessionCache(); err != nil {
+ fmt.Fprintln(os.Stderr, "error clearing cached session:", err)
+ os.Exit(1)
+ }
+ fmt.Println("Cleared cached session.")
+}
diff --git a/cmd_oauth_api.go b/cmd_oauth_api.go
new file mode 100644
index 0000000..91b330c
--- /dev/null
+++ b/cmd_oauth_api.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// This file's two commands are honestly NOT output-identical to their
+// direct-DB counterparts, unlike cmd_users_api.go — api's
+// GET /admin/oauth/health reports one "configured" bool per provider,
+// not separately whether the client ID or the client secret is what's
+// missing, so that finer distinction (which direct-DB mode's `list`
+// shows) simply isn't knowable through this endpoint. In exchange,
+// api's health check does a real, live reachability probe for every
+// provider in a single call — something direct-DB mode's `list` never
+// did at all, only its separate `test` command did per-provider. Both
+// modes are genuinely useful, they're just not the same shape, and
+// pretending otherwise would hide real information either way.
+
+type apiOAuthHealthEntry struct {
+ Provider string `json:"provider"`
+ Configured bool `json:"configured"`
+ Status string `json:"status"`
+ HTTPStatus int `json:"http_status"`
+ LatencyMS int `json:"latency_ms"`
+}
+
+func fetchOAuthHealth(cfg csaxConfig) ([]apiOAuthHealthEntry, error) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ return nil, err
+ }
+ var entries []apiOAuthHealthEntry
+ if err := client.get(cfg, "/admin/oauth/health", &entries); err != nil {
+ return nil, err
+ }
+ return entries, nil
+}
+
+func cmdOAuthProvidersListAPI(cfg csaxConfig, jsonOutput bool) {
+ entries, err := fetchOAuthHealth(cfg)
+ if err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ if jsonOutput {
+ b, _ := json.MarshalIndent(entries, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+
+ columns := []string{"PROVIDER", "CONFIGURED", "STATUS", "HTTP", "LATENCY"}
+ var rows [][]string
+ for _, e := range entries {
+ httpCol, latencyCol := "-", "-"
+ if e.HTTPStatus != 0 {
+ httpCol = fmt.Sprintf("%d", e.HTTPStatus)
+ }
+ if e.LatencyMS != 0 {
+ latencyCol = fmt.Sprintf("%dms", e.LatencyMS)
+ }
+ rows = append(rows, []string{e.Provider, yesNo(e.Configured), oauthStatusColor(e.Status), httpCol, latencyCol})
+ }
+ printTable(columns, rows)
+ fmt.Println(dim("\nStatus and reachability are live, from the deployment's own perspective — csax never contacted any provider directly for this."))
+}
+
+func oauthStatusColor(status string) string {
+ switch status {
+ case "ok":
+ return green(status)
+ case "degraded", "unreachable":
+ return red(status)
+ default:
+ return dim(status)
+ }
+}
+
+// cmdOAuthTestAPI is one entry from the same health call, filtered to
+// the provider asked for — api's health check has no concept of
+// testing "one provider on demand" separate from checking all of
+// them, so this doesn't cost any less than providers list itself.
+func cmdOAuthTestAPI(cfg csaxConfig, providerName string) {
+ entries, err := fetchOAuthHealth(cfg)
+ if err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ for _, e := range entries {
+ if e.Provider != providerName {
+ continue
+ }
+ fmt.Printf("Provider: %s\n", e.Provider)
+ fmt.Printf("Configured: %s\n", yesNo(e.Configured))
+ fmt.Printf("Status: %s\n", oauthStatusColor(e.Status))
+ if e.HTTPStatus != 0 {
+ fmt.Printf("HTTP: %d\n", e.HTTPStatus)
+ }
+ if e.LatencyMS != 0 {
+ fmt.Printf("Latency: %dms\n", e.LatencyMS)
+ }
+ fmt.Println(dim("\nThis is api's own live reachability probe, not a redirect_uri check — csax cannot verify a redirect_uri is registered correctly through this endpoint the way direct-DB mode's test does."))
+ if e.Status != "ok" {
+ os.Exit(1)
+ }
+ return
+ }
+ fmt.Println(red("unknown or unconfigured provider: " + providerName))
+ os.Exit(1)
+}
diff --git a/cmd_users_api.go b/cmd_users_api.go
new file mode 100644
index 0000000..4f67596
--- /dev/null
+++ b/cmd_users_api.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// This file mirrors cmd_users_list.go/cmd_users.go's *output shapes*
+// exactly, on purpose — a script running `csax users list --json`
+// must get the same fields whether the deployment is reached over
+// Postgres or over api's HTTP admin surface. Only the internal query
+// path differs.
+//
+// users create/unlock have no equivalent here at all — see
+// CLAUDE.md's "two reach modes" section for why that's permanent,
+// not a gap this file is expected to eventually close.
+
+// apiUserRecord is the subset of api's GET /admin/users response this
+// file actually uses — api returns more fields (updated_at,
+// locked_until, failed_attempts) but cmdUsersListAPI only surfaces
+// what cmdUsersList's own JSON shape already promises, to keep that
+// shape identical between modes.
+type apiUserRecord struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Created string `json:"created_at"`
+ Locked bool `json:"locked"`
+}
+
+// cmdUsersListAPI is users list's API-client path. api's own search
+// has no plain browse-everyone mode distinct from a query — an empty
+// q parameter is how this asks for one; see api's admin.mdx.
+func cmdUsersListAPI(cfg csaxConfig, limit, offset int, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+
+ var resp struct {
+ Users []apiUserRecord `json:"users"`
+ Total int `json:"total"`
+ }
+ path := fmt.Sprintf("/admin/users?limit=%d&offset=%d", limit, offset)
+ if err := client.get(cfg, path, &resp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ type userRow struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ CreatedAt string `json:"created_at"`
+ Locked bool `json:"locked"`
+ }
+ users := make([]userRow, 0, len(resp.Users))
+ for _, u := range resp.Users {
+ users = append(users, userRow{ID: u.ID, Email: u.Email, CreatedAt: u.Created, Locked: u.Locked})
+ }
+
+ if jsonOutput {
+ b, _ := json.MarshalIndent(users, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+
+ if len(users) == 0 {
+ fmt.Println(dim("No users found."))
+ return
+ }
+ for _, u := range users {
+ lockLabel := ""
+ if u.Locked {
+ lockLabel = yellow(" [locked]")
+ }
+ fmt.Printf("%s %-40s %s%s\n", u.ID, u.Email, u.CreatedAt, lockLabel)
+ }
+ fmt.Println(dim(fmt.Sprintf("\nShowing %d of %d (limit=%d offset=%d) — use --limit/--offset to page.", len(users), resp.Total, limit, offset)))
+}
+
+// cmdUsersGetAPI is users get's API-client path. Two round trips
+// where direct-DB mode needed one query: a search for the exact
+// email, then a detail fetch for the account it finds — that's the
+// accepted cost of not holding a database credential.
+func cmdUsersGetAPI(cfg csaxConfig, email string, jsonOutput bool) {
+ client, err := newAPIClient(cfg)
+ if err != nil {
+ fmt.Println(red("error: " + err.Error()))
+ os.Exit(1)
+ }
+
+ var searchResp struct {
+ Users []apiUserRecord `json:"users"`
+ Match string `json:"match"`
+ }
+ if err := client.get(cfg, "/admin/users?q="+email, &searchResp); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+ if len(searchResp.Users) == 0 {
+ fmt.Println(red("failed to find user: no account matches " + email))
+ os.Exit(1)
+ }
+ found := searchResp.Users[0]
+
+ var detail struct {
+ User struct {
+ LockedUntil *string `json:"locked_until"`
+ FailedAttempts int `json:"failed_attempts"`
+ } `json:"user"`
+ ActiveSessions int `json:"active_sessions"`
+ }
+ if err := client.get(cfg, "/admin/users/"+found.ID, &detail); err != nil {
+ fmt.Println(red("request failed: " + err.Error()))
+ os.Exit(1)
+ }
+
+ if jsonOutput {
+ out := map[string]any{
+ "id": found.ID,
+ "email": found.Email,
+ "created_at": found.Created,
+ "locked": found.Locked,
+ "locked_until": detail.User.LockedUntil,
+ "failed_attempts": detail.User.FailedAttempts,
+ "active_sessions": detail.ActiveSessions,
+ }
+ b, _ := json.MarshalIndent(out, "", " ")
+ fmt.Println(string(b))
+ return
+ }
+
+ fmt.Printf("ID: %s\n", found.ID)
+ fmt.Printf("Email: %s\n", found.Email)
+ fmt.Printf("Created: %s\n", found.Created)
+ if detail.User.LockedUntil != nil {
+ fmt.Println(yellow(fmt.Sprintf("Locked: yes, until %s", *detail.User.LockedUntil)))
+ } else {
+ fmt.Printf("Locked: no (failed attempts: %d)\n", detail.User.FailedAttempts)
+ }
+ fmt.Printf("Sessions: %d active\n", detail.ActiveSessions)
+}
diff --git a/config.go b/config.go
index ac0f4d2..543899d 100644
--- a/config.go
+++ b/config.go
@@ -33,6 +33,14 @@ type csaxConfig struct {
AIAPIKeyEnv string // name of the env var holding the API key — never the key itself, so it's not persisted in .env in plaintext by `config init`
AIModel string
ReadOnlyDBURL string // separate connection string, MUST point at a read-only Postgres role — this is the real safety boundary, not just ai.validateIntent
+
+ // API-client mode — reach a deployed api instance over HTTP,
+ // authenticated as an operator, instead of connecting to Postgres
+ // directly. See apiclient.go. Unset (the default) leaves every
+ // command working exactly as it does today.
+ APIURL string
+ APIEmail string
+ APIPassword string // optional; prompted interactively if unset — never require it in .env in plaintext
}
func loadConfig() (csaxConfig, error) {
@@ -54,12 +62,23 @@ func loadConfig() (csaxConfig, error) {
AIAPIKeyEnv: os.Getenv("AI_API_KEY_ENV"),
AIModel: os.Getenv("AI_MODEL"),
ReadOnlyDBURL: os.Getenv("READONLY_DATABASE_URL"),
+
+ APIURL: strings.TrimRight(os.Getenv("CSAX_API_URL"), "/"),
+ APIEmail: os.Getenv("CSAX_API_EMAIL"),
+ APIPassword: os.Getenv("CSAX_API_PASSWORD"),
}
if cfg.MigrationsDir == "" {
cfg.MigrationsDir = "./migrations"
}
- if cfg.DatabaseURL == "" {
- return cfg, fmt.Errorf("DATABASE_URL is required — set it in .env or run `csax config init`")
+ // DATABASE_URL is only required for direct-DB mode. With
+ // CSAX_API_URL set, a command reaches the deployment over HTTP as
+ // an operator and never needs a database credential at all — that
+ // is the entire point of API-client mode, so this check must not
+ // apply when it's in use. connectDB/buildEngine still enforce
+ // DATABASE_URL for the direct-DB commands that always need it
+ // (csax migrate, and any command run without CSAX_API_URL set).
+ if cfg.DatabaseURL == "" && cfg.APIURL == "" {
+ return cfg, fmt.Errorf("DATABASE_URL is required for direct-DB mode — set it in .env, or set CSAX_API_URL to use API-client mode instead")
}
return cfg, nil
}
diff --git a/go.mod b/go.mod
index d833c1d..231701b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,12 +3,27 @@ module github.com/crydensync/csax
go 1.25.0
require (
- github.com/crydensync/cryden/v2 v2.1.0
+ github.com/crydensync/cryden/v2 v2.5.0
github.com/lib/pq v1.12.3
+ golang.org/x/term v0.45.0
)
require (
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.3 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/go-webauthn/webauthn v0.18.0 // indirect
+ github.com/go-webauthn/x v0.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
- golang.org/x/crypto v0.54.0 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/pquerna/otp v1.5.0 // indirect
+ github.com/redis/go-redis/v9 v9.22.0 // indirect
+ github.com/tinylib/msgp v1.6.4 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
)
diff --git a/go.sum b/go.sum
index c4fc1b2..4cd84fb 100644
--- a/go.sum
+++ b/go.sum
@@ -1,10 +1,62 @@
-github.com/crydensync/cryden/v2 v2.1.0 h1:qU35YuSI2g6pQd+b0s0XcQrWuDVTtEyH53ly61C2qtI=
-github.com/crydensync/cryden/v2 v2.1.0/go.mod h1:kkLk2779IPHbbj7aBmieH2jZRkwWGzyTmHHWKojAQEQ=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/crydensync/cryden/v2 v2.5.0 h1:hIeLxF3SGnZgN7ZyuvTS6WsjmdwLoiRY8LqSunE09pY=
+github.com/crydensync/cryden/v2 v2.5.0/go.mod h1:SNrasjDCREkJoSjE++/+jXG1QpdCB6vjQMEp8YKE1/8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ=
+github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI=
+github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
+github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA=
+github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg=
+github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE=
+github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
+github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
diff --git a/main.go b/main.go
index 0e7e0a8..bd2f8b8 100644
--- a/main.go
+++ b/main.go
@@ -10,21 +10,30 @@ import (
)
const logo = `
- ▄████████▄ ▄▄▄
- █ ██████▀▘ ▄▄▄▄▄ csax — CrydenSync admin CLI
- █ ▀▀▀▀▘ ▀▀▀▀▀▘ self-hosted auth, owned by you
+▟▀▀ ▟▀▀ ▟▀▙ ▚▞
+▜▄▄ ▄▄▛ █▀█ ▞▚
+ csax · CrydenSync admin CLI
+ self-hosted auth, owned by you
`
func usage() {
- fmt.Println(`csax — CrydenSync admin CLI
+ fmt.Println(`csax · CrydenSync admin CLI
Usage:
csax config init
+ csax login
+ csax logout
csax migrate up|down|status
csax users list [--limit N] [--offset N] [--json]
csax users get [--json]
csax users create
csax users unlock
+ csax digest [--since ] [--json]
+ csax digest history [--limit N] [--json] (API-client mode only)
+ csax support diagnose [--json]
+ csax config-tuning [--window-days N] [--json]
+ csax anomalies list [--status S] [--limit N] [--json] (API-client mode only)
+ csax anomalies review --status S [--note "..."] (API-client mode only)
csax sessions list --user [--json]
csax sessions revoke --user
csax sessions revoke-all --user
@@ -47,10 +56,12 @@ Usage:
csax health
csax version
-oauth and ai commands are optional — see README for the env vars each
+oauth and ai commands are optional see README for the env vars each
one needs, or run ` + "`csax ai config`" + ` / ` + "`csax oauth config`" + ` for an
-interactive setup. Run any command with no further args for its
-specific usage.`)
+interactive setup. Setting CSAX_API_URL switches supported commands to
+call a deployed api instance over HTTP as an operator, instead of
+connecting to Postgres directly run ` + "`csax login`" + ` first. Run any
+command with no further args for its specific usage.`)
}
func main() {
@@ -70,8 +81,23 @@ func main() {
}
cmdConfigInit(os.Args[3:])
+ case "login":
+ cmdLogin(mustLoadConfig())
+
+ case "logout":
+ cmdLogout()
+
case "migrate":
cfg := mustLoadConfig()
+ // migrate always stays direct-DB, even with CSAX_API_URL set —
+ // there is no sane way to run a schema migration through an
+ // HTTP admin API. Check this explicitly rather than letting an
+ // empty DATABASE_URL surface as a confusing connection error
+ // from mustConnect below.
+ if cfg.DatabaseURL == "" {
+ fmt.Fprintln(os.Stderr, "DATABASE_URL is required for `csax migrate` — it always runs directly against Postgres, regardless of CSAX_API_URL")
+ os.Exit(1)
+ }
db := mustConnect(cfg)
defer db.Close()
if len(os.Args) < 3 {
@@ -92,8 +118,6 @@ func main() {
case "users":
cfg := mustLoadConfig()
- db := mustConnect(cfg)
- defer db.Close()
if len(os.Args) < 3 {
fmt.Println("usage: csax users list|get|create|unlock [args]")
os.Exit(1)
@@ -105,7 +129,13 @@ func main() {
offset := fs.Int("offset", 0, "pagination offset")
jsonOut := fs.Bool("json", false, "output as JSON")
fs.Parse(os.Args[3:])
- cmdUsersList(db, *limit, *offset, *jsonOut)
+ if apiMode(cfg) {
+ cmdUsersListAPI(cfg, *limit, *offset, *jsonOut)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdUsersList(db, *limit, *offset, *jsonOut)
+ }
case "get":
if len(os.Args) < 4 {
fmt.Println("usage: csax users get [--json]")
@@ -114,25 +144,133 @@ func main() {
fs := flag.NewFlagSet("users get", flag.ExitOnError)
jsonOut := fs.Bool("json", false, "output as JSON")
fs.Parse(os.Args[4:])
- cmdUsersGet(db, os.Args[3], *jsonOut)
+ if apiMode(cfg) {
+ cmdUsersGetAPI(cfg, os.Args[3], *jsonOut)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdUsersGet(db, os.Args[3], *jsonOut)
+ }
case "create":
+ // Always direct-DB — api has no admin-create-user
+ // endpoint at all, deliberately, see CLAUDE.md.
if len(os.Args) < 5 {
fmt.Println("usage: csax users create ")
os.Exit(1)
}
+ db := mustConnect(cfg)
+ defer db.Close()
engine := mustBuildEngine(cfg, db)
cmdUsersCreate(engine, os.Args[3], os.Args[4])
case "unlock":
+ // Always direct-DB — api's admin console deliberately has
+ // no unlock endpoint, see CLAUDE.md.
if len(os.Args) < 4 {
fmt.Println("usage: csax users unlock ")
os.Exit(1)
}
+ db := mustConnect(cfg)
+ defer db.Close()
cmdUsersUnlock(db, os.Args[3])
default:
fmt.Println("usage: csax users list|get|create|unlock [args]")
os.Exit(1)
}
+ case "digest":
+ cfg := mustLoadConfig()
+ if len(os.Args) >= 3 && os.Args[2] == "history" {
+ if !apiMode(cfg) {
+ fmt.Println("`csax digest history` is API-client mode only — set CSAX_API_URL. cryden's own WeeklyDigest only ever answers on demand; there is no local, direct-DB concept of scheduled digest history to read.")
+ os.Exit(1)
+ }
+ fs := flag.NewFlagSet("digest history", flag.ExitOnError)
+ limit := fs.Int("limit", 20, "how many past digests to show")
+ jsonOut := fs.Bool("json", false, "output as JSON")
+ fs.Parse(os.Args[3:])
+ cmdDigestHistoryAPI(cfg, *limit, *jsonOut)
+ break
+ }
+ fs := flag.NewFlagSet("digest", flag.ExitOnError)
+ since := fs.Duration("since", 0, "how far back to look (default: 7 days, or api's own default)")
+ jsonOut := fs.Bool("json", false, "output as JSON")
+ fs.Parse(os.Args[2:])
+ if apiMode(cfg) {
+ sinceDays := 0
+ if *since > 0 {
+ sinceDays = int(since.Hours() / 24)
+ }
+ cmdDigestAPI(cfg, sinceDays, *jsonOut)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdDigest(cfg, db, *since, *jsonOut)
+ }
+
+ case "support":
+ cfg := mustLoadConfig()
+ if len(os.Args) < 4 || os.Args[2] != "diagnose" {
+ fmt.Println("usage: csax support diagnose [--json]")
+ os.Exit(1)
+ }
+ fs := flag.NewFlagSet("support diagnose", flag.ExitOnError)
+ jsonOut := fs.Bool("json", false, "output as JSON")
+ fs.Parse(os.Args[4:])
+ if apiMode(cfg) {
+ cmdSupportDiagnoseAPI(cfg, os.Args[3], *jsonOut)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdSupportDiagnose(cfg, db, os.Args[3], *jsonOut)
+ }
+
+ case "config-tuning":
+ cfg := mustLoadConfig()
+ fs := flag.NewFlagSet("config-tuning", flag.ExitOnError)
+ windowDays := fs.Int("window-days", 0, "reporting window in days (default: 30, cryden's own default)")
+ jsonOut := fs.Bool("json", false, "output as JSON")
+ fs.Parse(os.Args[2:])
+ if apiMode(cfg) {
+ cmdConfigTuningAPI(cfg, *windowDays, *jsonOut)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdConfigTuning(cfg, db, *windowDays, *jsonOut)
+ }
+
+ case "anomalies":
+ cfg := mustLoadConfig()
+ if len(os.Args) < 3 {
+ fmt.Println("usage: csax anomalies list|review [args]")
+ os.Exit(1)
+ }
+ switch os.Args[2] {
+ case "list":
+ fs := flag.NewFlagSet("anomalies list", flag.ExitOnError)
+ status := fs.String("status", "", "filter by status: unreviewed, confirmed, dismissed")
+ limit := fs.Int("limit", 50, "max events to show")
+ jsonOut := fs.Bool("json", false, "output as JSON")
+ fs.Parse(os.Args[3:])
+ cmdAnomaliesList(cfg, *status, *limit, *jsonOut)
+ case "review":
+ if len(os.Args) < 4 {
+ fmt.Println("usage: csax anomalies review --status S [--note \"...\"]")
+ os.Exit(1)
+ }
+ fs := flag.NewFlagSet("anomalies review", flag.ExitOnError)
+ status := fs.String("status", "", "required: unreviewed, confirmed, or dismissed")
+ note := fs.String("note", "", "optional note, max 500 characters")
+ fs.Parse(os.Args[4:])
+ if *status == "" {
+ fmt.Println("usage: csax anomalies review --status S [--note \"...\"]")
+ os.Exit(1)
+ }
+ cmdAnomaliesReview(cfg, os.Args[3], *status, *note)
+ default:
+ fmt.Println("usage: csax anomalies list|review [args]")
+ os.Exit(1)
+ }
+
case "sessions":
cfg := mustLoadConfig()
db := mustConnect(cfg)
@@ -227,7 +365,11 @@ func main() {
fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError)
jsonOut := fs.Bool("json", false, "output as JSON")
fs.Parse(os.Args[4:])
- cmdOAuthProvidersList(cfg, *jsonOut)
+ if apiMode(cfg) {
+ cmdOAuthProvidersListAPI(cfg, *jsonOut)
+ } else {
+ cmdOAuthProvidersList(cfg, *jsonOut)
+ }
case "add":
if len(os.Args) < 5 {
fmt.Println("usage: csax oauth providers add ")
@@ -243,7 +385,11 @@ func main() {
fmt.Println("usage: csax oauth test ")
os.Exit(1)
}
- cmdOAuthTest(cfg, os.Args[3])
+ if apiMode(cfg) {
+ cmdOAuthTestAPI(cfg, os.Args[3])
+ } else {
+ cmdOAuthTest(cfg, os.Args[3])
+ }
case "users":
if len(os.Args) < 5 || os.Args[3] != "get" {
fmt.Println("usage: csax oauth users get [--json]")
@@ -337,9 +483,13 @@ func main() {
case "health":
cfg := mustLoadConfig()
- db := mustConnect(cfg)
- defer db.Close()
- cmdHealth(db)
+ if apiMode(cfg) {
+ cmdHealthAPI(cfg)
+ } else {
+ db := mustConnect(cfg)
+ defer db.Close()
+ cmdHealth(db)
+ }
case "version":
cmdVersion()