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.
314 lines
9.8 KiB
Go
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)
|
|
}
|