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.
This commit is contained in:
1 parent
b792ad256e
commit
0a6023e942
10 files changed
+4138
No files matched your search
@@ -0,0 +1,313 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user