Files
bryanpedini 0a6023e942 feat(cli): add help, token and audit commands
Entry point and dispatch, token list [all] / new / delete, audit [all | n | n<unit>] with padded table output, help, usage and exit codes; unit and e2e tests for commands, configuration precedence, logging and token lifecycle.
2026-10-06 10:52:25 +02:00

314 lines
9.8 KiB
Go

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"math"
"strconv"
"strings"
"text/tabwriter"
"time"
)
// tokenListHeader holds the header cells of `token list`
var tokenListHeader = []string{"TOKEN", "DESCRIPTION", "ACTIVE", "CREATED_AT", "DELETED_AT"}
// auditListHeader holds the header cells of `audit`
var auditListHeader = []string{"TIME", "TOKEN", "ORIGIN", "OPERATION"}
// tokenListAll is the only argument accepted by `token list` (case-sensitive)
const tokenListAll = "all"
// auditAll is the only word accepted by `audit` (case-sensitive): print every row
const auditAll = "all"
// defaultAuditLimit is the number of rows printed by `audit` without arguments
const defaultAuditLimit = 10
// auditUnits maps the window unit letters (lowercase only) to their duration
var auditUnits = map[byte]time.Duration{
's': time.Second,
'm': time.Minute,
'h': time.Hour,
'd': 24 * time.Hour,
'w': 7 * 24 * time.Hour,
}
// auditSelection is a parsed audit argument: exactly one of all, limit > 0 and window > 0 holds
type auditSelection struct {
all bool // print every row
limit int // latest rows to print
window time.Duration // print the rows with time >= now - window
}
// tokenCommand is a parsed token subcommand ready to run against a store
type tokenCommand func(ctx context.Context, store *TokenStore) error
// parseTokenCommand validates the arguments following "token" without touching the database
func (a *app) parseTokenCommand(args []string) (string, tokenCommand, error) {
if len(args) == 0 {
return "", nil, usageErrorf("missing token subcommand")
}
switch args[0] {
case "list":
if len(args) > 2 {
return "", nil, usageErrorf("token list takes at most one argument")
}
if len(args) == 2 && args[1] != tokenListAll {
return "", nil, usageErrorf("unknown token list argument")
}
activeOnly := len(args) == 1
return "token list", func(ctx context.Context, store *TokenStore) error {
return a.tokenList(ctx, store, activeOnly)
}, nil
case "new":
if len(args) > 2 {
return "", nil, usageErrorf("token new takes at most one argument")
}
description := ""
if len(args) == 2 {
description = args[1]
}
return "token new", func(ctx context.Context, store *TokenStore) error {
return a.tokenNew(ctx, store, description)
}, nil
case "delete":
if len(args) != 2 {
return "", nil, usageErrorf("token delete requires exactly one argument")
}
value := args[1]
return "token delete", func(ctx context.Context, store *TokenStore) error {
return a.tokenDelete(ctx, store, value)
}, nil
default:
return "", nil, usageErrorf("unknown token subcommand")
}
}
// runTokenCommand parses and executes a token subcommand
func (a *app) runTokenCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) (err error) {
defer traceFunc(ctx, log, "app.runTokenCommand")()
// Parse first: usage errors never touch the database
name, cmd, err := a.parseTokenCommand(args)
if err != nil {
return err
}
log.DebugContext(ctx, "cli dispatch", "command", name)
return a.withDatabase(ctx, cfg, log, name, func(db *sql.DB) error {
return cmd(ctx, NewTokenStore(db, log, a.now, a.generateToken))
})
}
// withDatabase opens the database, runs the named CLI command against it and closes it
func (a *app) withDatabase(ctx context.Context, cfg Config, log *slog.Logger, name string, cmd func(db *sql.DB) error) (err error) {
// Open the database
db, err := OpenDatabase(ctx, cfg.DBPath, log)
if err != nil {
return err
}
defer closeDatabase(ctx, db, log, &err)
// Execute
err = cmd(db)
log.DebugContext(ctx, "cli command finished", "command", name, "ok", err == nil)
return err
}
// writeTable prints the header and the rows as a column-aligned table
// Every column but the last is padded with spaces to its widest cell (in runes) plus 2; lines carry no trailing whitespace
func writeTable(out io.Writer, header []string, rows [][]string) error {
w := bufio.NewWriter(out)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(header, "\t"))
for _, row := range rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
}
// Write errors are reported once, keeping the first one
err := tw.Flush()
if ferr := w.Flush(); err == nil {
err = ferr
}
if err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}
// formatTokenRow renders the cells of one `token list` line
// The description is Go-quoted so every row is exactly one line whatever its content
func formatTokenRow(tok Token) []string {
deletedAt := "-"
if tok.DeletedAt != nil {
deletedAt = formatTime(*tok.DeletedAt)
}
return []string{tok.Token, strconv.Quote(tok.Description), strconv.FormatBool(tok.Active), formatTime(tok.CreatedAt), deletedAt}
}
// tokenList prints the header and the active tokens, or every token when activeOnly is false
func (a *app) tokenList(ctx context.Context, store *TokenStore, activeOnly bool) error {
defer traceFunc(ctx, store.log, "app.tokenList")()
tokens, err := store.ListTokens(ctx, activeOnly)
if err != nil {
return err
}
rows := make([][]string, 0, len(tokens))
for _, tok := range tokens {
rows = append(rows, formatTokenRow(tok))
}
return writeTable(a.stdout, tokenListHeader, rows)
}
// tokenNew creates a token and prints it
func (a *app) tokenNew(ctx context.Context, store *TokenStore, description string) error {
defer traceFunc(ctx, store.log, "app.tokenNew")()
tok, err := store.CreateToken(ctx, description)
if err != nil {
return err
}
store.log.DebugContext(ctx, "token created", "token_id", tokenID(tok.Token))
if _, err := fmt.Fprintln(a.stdout, tok.Token); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}
// tokenDelete deactivates a token; nothing is printed on success
func (a *app) tokenDelete(ctx context.Context, store *TokenStore, value string) error {
defer traceFunc(ctx, store.log, "app.tokenDelete")()
if err := store.DeactivateToken(ctx, value); err != nil {
return err
}
store.log.DebugContext(ctx, "token deactivated", "token_id", tokenID(value))
return nil
}
// parseAuditArgs validates the arguments following "audit" without touching the database
// Accepted: nothing (latest 10 rows), all (every row), <n> (latest n rows), <n><unit> (rows of the last n units)
// all is exact and case-sensitive; n is ASCII decimal digits only (leading zeros allowed) and at least 1; the argument is never echoed
func parseAuditArgs(args []string) (auditSelection, error) {
switch len(args) {
case 0:
return auditSelection{limit: defaultAuditLimit}, nil
case 1:
default:
return auditSelection{}, usageErrorf("audit takes at most one argument")
}
if args[0] == auditAll {
return auditSelection{all: true}, nil
}
// Split an optional trailing unit letter
digits, unit := args[0], time.Duration(0)
if n := len(digits); n > 0 {
if u, ok := auditUnits[digits[n-1]]; ok {
digits, unit = digits[:n-1], u
}
}
// The count must be a positive decimal integer that fits an int
n, err := parsePositiveDecimal(digits)
if err != nil {
return auditSelection{}, usageErrorf("invalid audit argument")
}
if unit == 0 {
return auditSelection{limit: n}, nil
}
// The window must fit a time.Duration
if int64(n) > math.MaxInt64/int64(unit) {
return auditSelection{}, usageErrorf("invalid audit argument")
}
return auditSelection{window: time.Duration(n) * unit}, nil
}
// parsePositiveDecimal accepts only ASCII decimal digits (no sign, no whitespace) with a value >= 1 that fits an int
func parsePositiveDecimal(s string) (int, error) {
if s == "" {
return 0, errors.New("empty number")
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, errors.New("not a decimal integer")
}
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n < 1 {
return 0, errors.New("number must be at least 1")
}
return n, nil
}
// runAuditCommand parses and executes the audit command
func (a *app) runAuditCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) error {
defer traceFunc(ctx, log, "app.runAuditCommand")()
// Parse first: usage errors never touch the database
sel, err := parseAuditArgs(args)
if err != nil {
return err
}
switch {
case sel.all:
log.DebugContext(ctx, "cli dispatch", "command", "audit", "all", true)
case sel.window > 0:
log.DebugContext(ctx, "cli dispatch", "command", "audit", "window", sel.window)
default:
log.DebugContext(ctx, "cli dispatch", "command", "audit", "limit", sel.limit)
}
return a.withDatabase(ctx, cfg, log, "audit", func(db *sql.DB) error {
return a.auditList(ctx, NewAuditStore(db, log, a.now), sel)
})
}
// formatOperationRow renders the cells of one `audit` line
// Time and token are printed exactly as stored; origin and operation are written by the server and contain no tabs or newlines
func formatOperationRow(op Operation) []string {
return []string{op.TimeText, op.Token, op.Origin, op.Operation}
}
// auditList prints the header and the selected audit rows, oldest first
func (a *app) auditList(ctx context.Context, store *AuditStore, sel auditSelection) error {
defer traceFunc(ctx, store.log, "app.auditList")()
// Resolve the selection against the clock; the zero query selects every row
var q OperationQuery
switch {
case sel.all:
case sel.window > 0:
since := a.now().Add(-sel.window)
q.Since = &since
store.log.DebugContext(ctx, "audit window resolved", "since", formatTime(since))
default:
q.Limit = sel.limit
}
ops, err := store.ListOperations(ctx, q)
if err != nil {
return err
}
store.log.DebugContext(ctx, "audit rows listed", "rows", len(ops))
rows := make([][]string, 0, len(ops))
for _, op := range ops {
rows = append(rows, formatOperationRow(op))
}
return writeTable(a.stdout, auditListHeader, rows)
}