Files
emcli/internal/cli/interactive.go
T
steve 9d946b1b03 feat(cli): two-key role routing + init bootstrap
openStore(role) selects the DEK wrap slot; admin commands require
EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both
slots from both keys. Test helpers seed the wrap slots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:59:16 +01:00

118 lines
3.2 KiB
Go

package cli
import (
"fmt"
"io"
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"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, writes both DEK wrap slots, 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 {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init")
return 0
}
adminKey, err := crypto.AdminKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
agentKey, err := crypto.AgentKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
path, err := store.DefaultDBPath()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
st, err := store.Open(path)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := st.InitKeys(adminKey, agentKey); err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
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)
}