feat(admin): Phase 4 — doctor, admin completeness, and bubbletea TUI

Adds the admin/diagnostics surface from SPEC §7.2:

- doctor [--account]: per-account IMAP + (RW) SMTP connectivity/auth checks via
  new mail.CheckIMAP/CheckSMTP (connect+auth only, no mail). Exit non-zero on any
  failure; secrets never printed.
- store.UpdateAccount: partial edit, re-encrypts password/secrets only when a
  non-empty value is supplied (blank keeps existing). RecentAuditFor(account).
- config set/get (validates audit_retention_days), audit list [--account][--limit],
  account edit (flag partial-update) / remove [--yes].
- internal/tui: bubbletea AccountForm with pure, fully-tested Fields (validation +
  store.Account assembly + edit prefill). init / bare `account add` / `account edit
  --name X` drop into the TUI; flag forms remain for scripting.

Built test-first; full suite green incl -race. Validated live against the mxlogin
(password) and Gmail (app-password) accounts. Live validation caught a real bug:
doctor authenticated with empty passwords because it iterated ListAccounts (which
strips secrets) — fixed to re-fetch via GetAccount, locked in by a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:09:43 +01:00
parent 193815dd25
commit a837b25d73
20 changed files with 1535 additions and 10 deletions
+179
View File
@@ -4,8 +4,10 @@ import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
@@ -24,6 +26,9 @@ func runAccount(args []string, out, errOut io.Writer) int {
switch sub {
case "add":
if len(rest) == 0 { // no flags → interactive TUI form
return addInteractive(st, tui.Fields{}, out, errOut)
}
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
@@ -63,6 +68,91 @@ func runAccount(args []string, out, errOut io.Writer) int {
}
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
return 0
case "edit":
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
mode := fs.String("mode", "", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 0, "IMAP port")
sec := fs.String("imap-security", "", "tls|starttls")
smtpHost := fs.String("smtp-host", "", "SMTP host")
smtpPort := fs.Int("smtp-port", 0, "SMTP port")
smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password (blank keeps existing)")
subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut)
}
acc, err := st.GetAccount(*name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
// Overlay only the flags the user actually set.
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "mode":
acc.Mode = *mode
case "imap-host":
acc.IMAPHost = *host
case "imap-port":
acc.IMAPPort = *port
case "imap-security":
acc.IMAPSecurity = *sec
case "smtp-host":
acc.SMTPHost = *smtpHost
case "smtp-port":
acc.SMTPPort = *smtpPort
case "smtp-security":
acc.SMTPSecurity = *smtpSec
case "username":
acc.Username = *user
case "password":
acc.Password = *pass
case "subject-regex":
acc.SubjectRegex = *subj
}
})
// acc.Password holds the existing (decrypted) password from GetAccount; the
// Visit above overwrites it only when --password was passed. UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved.
if err := st.UpdateAccount(acc); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", *name)
return 0
case "remove":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
return 2
}
if err := st.DeleteAccount(*name); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q removed\n", *name)
return 0
case "list":
accs, err := st.ListAccounts()
if err != nil {
@@ -81,6 +171,95 @@ func runAccount(args []string, out, errOut io.Writer) int {
}
}
// auditList renders recent audit entries (account "" = all) to out.
func auditList(st *store.Store, account string, limit int, out io.Writer) error {
entries, err := st.RecentAuditFor(account, limit)
if err != nil {
return err
}
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
"TS", "ACCOUNT", "ACTION", "RESULT", "TARGET", "REASON")
for _, e := range entries {
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
e.TS, e.Account, e.Action, e.Result, e.Target, e.Reason)
}
return nil
}
// runConfig handles `config set <key> <value>` and `config get <key>`.
func runConfig(args []string, out, errOut io.Writer) int {
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "set":
if len(args) < 3 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2
}
value := args[2]
if key == "audit_retention_days" {
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
return 2
}
}
if err := st.SetSetting(key, value); err != nil {
fmt.Fprintf(errOut, "config set: %v\n", err)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, value)
return 0
case "get":
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
default:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
return 2
}
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
func runAudit(args []string, out, errOut io.Writer) int {
if len(args) == 0 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
return 2
}
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := auditList(st, *account, *limit, out); err != nil {
fmt.Fprintf(errOut, "audit list: %v\n", err)
return 1
}
return 0
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int {
if len(args) < 2 {
+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"bytes"
"path/filepath"
"strings"
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
func adminEnv(t *testing.T) string {
t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_DB", db)
return db
}
func run(t *testing.T, args ...string) (int, string, string) {
t.Helper()
var out, errOut bytes.Buffer
code := Run(args, &out, &errOut)
return code, out.String(), errOut.String()
}
func TestConfigSetGet(t *testing.T) {
adminEnv(t)
if code, _, e := run(t, "config", "set", "audit_retention_days", "30"); code != 0 {
t.Fatalf("config set failed: %s", e)
}
code, out, _ := run(t, "config", "get", "audit_retention_days")
if code != 0 || !strings.Contains(out, "30") {
t.Fatalf("config get: code=%d out=%q", code, out)
}
}
func TestConfigSetRejectsBadRetention(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 {
t.Fatal("negative retention must be rejected")
}
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 {
t.Fatal("non-integer retention must be rejected")
}
}
func TestAccountRemove(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 {
t.Fatalf("remove failed: %s", e)
}
_, out, _ := run(t, "account", "list")
if strings.Contains(out, "gone") {
t.Fatalf("account still listed after remove:\n%s", out)
}
}
func TestAccountRemoveMissing(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "account", "remove", "--name", "nope", "--yes"); code == 0 {
t.Fatal("removing a missing account must be non-zero")
}
}
func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
db := adminEnv(t)
run(t, "account", "add", "--name", "ed", "--mode", "RO",
"--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig")
// Edit only mode + add SMTP; imap-host, username, password must be preserved.
if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW",
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e)
}
st, err := store.Open(db, mustKey())
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
got, err := st.GetAccount("ed")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Mode != "RW" || got.SMTPHost != "smtp.x.com" || got.SMTPPort != 587 {
t.Fatalf("edit didn't apply: %+v", got)
}
if got.IMAPHost != "imap.x.com" || got.Username != "u@x.com" || got.Password != "orig" {
t.Fatalf("edit clobbered preserved fields: %+v", got)
}
}
func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
var buf bytes.Buffer
if err := auditList(st, "", 50, &buf); err != nil {
t.Fatalf("auditList: %v", err)
}
out := buf.String()
if !strings.Contains(out, "list") || !strings.Contains(out, "whitelist_out") {
t.Fatalf("audit rows not rendered:\n%s", out)
}
}
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
func mustKey() []byte { return make([]byte, 32) }
+7 -5
View File
@@ -25,11 +25,13 @@ type Mailer interface {
}
type Deps struct {
Store *store.Store
Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) error
Now func() time.Time
Out io.Writer
Store *store.Store
Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) error
CheckIMAP func(store.Account) error
CheckSMTP func(store.Account) error
Now func() time.Time
Out io.Writer
}
func (d Deps) emit(e Envelope) error {
+3 -1
View File
@@ -18,7 +18,9 @@ type fakeMailer struct {
full map[uint32]mail.Message
}
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { return f.uidValidity, f.maxUID, nil }
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) {
return f.uidValidity, f.maxUID, nil
}
func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) {
if len(uids) == 0 {
return f.headers, nil
+61
View File
@@ -0,0 +1,61 @@
package cli
import "fmt"
// DoctorCmd runs connectivity/auth diagnostics. For each account (optionally
// filtered to one), it checks IMAP and — for RW accounts with an SMTP host —
// SMTP, printing a human-readable per-check result. It returns errCommandFailed
// if any check fails so the process can exit non-zero. Secrets are never printed.
func DoctorCmd(d Deps, account string) error {
accounts, err := d.Store.ListAccounts()
if err != nil {
fmt.Fprintf(d.Out, "FAIL: cannot list accounts: %v\n", err)
return errCommandFailed
}
anyFail := false
checked := 0
for _, listed := range accounts {
if account != "" && listed.Name != account {
continue
}
checked++
// ListAccounts strips secrets; re-fetch to get decrypted credentials.
a, err := d.Store.GetAccount(listed.Name)
if err != nil {
fmt.Fprintf(d.Out, "%s\n FAIL: %v\n", listed.Name, err)
anyFail = true
continue
}
fmt.Fprintf(d.Out, "%s (%s)\n", a.Name, a.Mode)
if err := d.CheckIMAP(a); err != nil {
fmt.Fprintf(d.Out, " IMAP FAIL: %v\n", err)
anyFail = true
} else {
fmt.Fprintf(d.Out, " IMAP ok\n")
}
switch {
case a.Mode != "RW":
fmt.Fprintf(d.Out, " SMTP n/a (read-only)\n")
case a.SMTPHost == "":
fmt.Fprintf(d.Out, " SMTP n/a (no smtp host configured)\n")
default:
if err := d.CheckSMTP(a); err != nil {
fmt.Fprintf(d.Out, " SMTP FAIL: %v\n", err)
anyFail = true
} else {
fmt.Fprintf(d.Out, " SMTP ok\n")
}
}
}
if account != "" && checked == 0 {
fmt.Fprintf(d.Out, "FAIL: account not found: %s\n", account)
return errCommandFailed
}
if anyFail {
return errCommandFailed
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"errors"
"path/filepath"
"strings"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { st.Close() })
for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil {
t.Fatalf("AddAccount %s: %v", a.Name, err)
}
}
buf := &[]byte{}
d := Deps{Store: st, CheckIMAP: imap, CheckSMTP: smtp, Out: bufWriter{buf}}
return d, buf
}
func roAcc(name string) store.Account {
return store.Account{Name: name, Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "u@x.com", Password: "p"}
}
func rwAcc(name string) store.Account {
a := roAcc(name)
a.Mode = "RW"
a.SMTPHost, a.SMTPPort, a.SMTPSecurity = "h", 465, "tls"
return a
}
func TestDoctorAllOK(t *testing.T) {
ok := func(store.Account) error { return nil }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd returned error when all checks pass: %v", err)
}
out := string(*buf)
if !strings.Contains(out, "work") || strings.Contains(strings.ToLower(out), "fail") {
t.Fatalf("unexpected report:\n%s", out)
}
}
func TestDoctorReportsSMTPFailure(t *testing.T) {
ok := func(store.Account) error { return nil }
bad := func(store.Account) error { return errors.New("auth rejected") }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, bad)
err := DoctorCmd(d, "")
if err == nil {
t.Fatal("DoctorCmd must return error when a check fails (non-zero exit)")
}
out := string(*buf)
if !strings.Contains(strings.ToLower(out), "fail") || !strings.Contains(out, "auth rejected") {
t.Fatalf("failure not reported:\n%s", out)
}
}
func TestDoctorSkipsSMTPForRO(t *testing.T) {
ok := func(store.Account) error { return nil }
smtpCalled := false
smtp := func(store.Account) error { smtpCalled = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("ro")}, ok, smtp)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if smtpCalled {
t.Fatal("SMTP check must be skipped for RO accounts")
}
}
func TestDoctorUsesDecryptedCredentials(t *testing.T) {
// roAcc has Password "p". ListAccounts strips secrets, so doctor must
// re-fetch the decrypted account before checking — otherwise the live
// check runs with an empty password.
var gotPassword string
imap := func(a store.Account) error { gotPassword = a.Password; return nil }
ok := func(store.Account) error { return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("work")}, imap, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if gotPassword != "p" {
t.Fatalf("check received password %q, want decrypted \"p\"", gotPassword)
}
}
func TestDoctorFiltersByAccount(t *testing.T) {
ok := func(store.Account) error { return nil }
checked := map[string]bool{}
imap := func(a store.Account) error { checked[a.Name] = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("a"), roAcc("b")}, imap, ok)
if err := DoctorCmd(d, "b"); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if checked["a"] || !checked["b"] {
t.Fatalf("account filter wrong: %v", checked)
}
}
+93
View File
@@ -0,0 +1,93 @@
package cli
import (
"fmt"
"io"
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runForm launches the bubbletea account form and returns the final model.
// This is interactive terminal glue (not unit-tested); all logic it relies on
// lives in the tui and store packages, which are tested.
func runForm(initial tui.Fields, editing bool) (tui.AccountForm, error) {
p := tea.NewProgram(tui.NewAccountForm(initial, editing))
m, err := p.Run()
if err != nil {
return tui.AccountForm{}, err
}
return m.(tui.AccountForm), nil
}
// addInteractive runs the form for a new account and persists it.
func addInteractive(st *store.Store, initial tui.Fields, out, errOut io.Writer) int {
form, err := runForm(initial, false)
if err != nil {
fmt.Fprintf(errOut, "form: %v\n", err)
return 1
}
if form.Cancelled() {
fmt.Fprintln(out, "cancelled")
return 1
}
acc := form.Account()
if _, err := st.AddAccount(acc); err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q added (%s)\n", acc.Name, acc.Mode)
return 0
}
// editInteractive runs the form prefilled from an existing account and saves it.
func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
acc, err := st.GetAccount(name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
form, err := runForm(tui.FieldsFromAccount(acc), true)
if err != nil {
fmt.Fprintf(errOut, "form: %v\n", err)
return 1
}
if form.Cancelled() {
fmt.Fprintln(out, "cancelled")
return 1
}
updated := form.Account()
if !form.PasswordSet() {
updated.Password = "" // blank ⇒ UpdateAccount keeps the existing password
}
if err := st.UpdateAccount(updated); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", name)
return 0
}
// runInit creates/opens the DB and adds the first account via the TUI form,
// seeding a default audit retention if unset.
func runInit(args []string, out, errOut io.Writer) int {
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90")
}
accs, _ := st.ListAccounts()
if len(accs) > 0 {
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
} else {
fmt.Fprintln(out, "Initializing emcli — add your first account.")
}
return addInteractive(st, tui.Fields{}, out, errOut)
}
+48 -1
View File
@@ -44,8 +44,47 @@ func realSender(acc store.Account, m mail.OutgoingMessage) error {
}, m)
}
func realCheckIMAP(acc store.Account) error {
return mail.CheckIMAP(mail.IMAPConfig{
Host: acc.IMAPHost, Port: acc.IMAPPort, Security: acc.IMAPSecurity,
Username: acc.Username, Password: acc.Password,
})
}
func realCheckSMTP(acc store.Account) error {
return mail.CheckSMTP(mail.SMTPConfig{
Host: acc.SMTPHost, Port: acc.SMTPPort, Security: acc.SMTPSecurity,
Username: acc.Username, Password: acc.Password,
})
}
func newDepsLive(st *store.Store, out io.Writer) Deps {
return Deps{Store: st, Dial: realMailer, Send: realSender, Now: time.Now, Out: out}
return Deps{
Store: st, Dial: realMailer, Send: realSender,
CheckIMAP: realCheckIMAP, CheckSMTP: realCheckSMTP,
Now: time.Now, Out: out,
}
}
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
func runDoctor(args []string, out, errOut io.Writer) int {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "check only this account")
if err := fs.Parse(args); err != nil {
return 2
}
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
d := newDepsLive(st, out)
if err := DoctorCmd(d, *account); err != nil {
return 1
}
return 0
}
// Run routes a command line and returns an exit code.
@@ -64,6 +103,14 @@ func Run(args []string, out, errOut io.Writer) int {
return runAccount(rest, out, errOut)
case "whitelist":
return runWhitelist(rest, out, errOut)
case "config":
return runConfig(rest, out, errOut)
case "audit":
return runAudit(rest, out, errOut)
case "doctor":
return runDoctor(rest, out, errOut)
case "init":
return runInit(rest, out, errOut)
default:
fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd)
return 2
+1 -1
View File
@@ -36,7 +36,7 @@ func TestListUsageErrorIsJSON(t *testing.T) {
// Agent command with a missing required flag emits a JSON error envelope.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting
t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting
code := Run([]string{"list"}, &out, &errOut) // missing --account
if code == 0 {
t.Fatal("missing --account should be non-zero")