From 377b62dd87bece66478571337149533468ac761c Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:14 +0100 Subject: [PATCH 01/19] feat: add terminal spinner for long-running operations Introduces withSpinner(), an async spinner shown around real network/DB round trips (AI provider calls, OAuth endpoint checks, read-only role verification). AI calls in particular took long enough during testing that a silent terminal looked hung rather than slow. Only spins on a real terminal with colors enabled; in piped/scripted output a spinner is noise and would corrupt line-by-line parsing, matching the existing colorsEnabled gating. The line is cleared after fn returns so no spinner text is left behind regardless of outcome. --- spinner.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 spinner.go diff --git a/spinner.go b/spinner.go new file mode 100644 index 0000000..2677d6a --- /dev/null +++ b/spinner.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "time" +) + +// withSpinner runs fn while showing a spinner, clearing it afterward +// regardless of outcome. Only spins on a real terminal with colors +// enabled — a spinner in piped/scripted output is just noise (and +// worse, corrupts anything parsing that output line by line), same +// reasoning as colorsEnabled gating everywhere else in csax. +// +// Used for anything that makes a real network/DB round trip — the +// AI provider calls in particular took long enough during testing +// that a silent terminal looked hung, not just slow. +func withSpinner(label string, fn func() error) error { + if !colorsEnabled { + return fn() + } + + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + done := make(chan struct{}) + go func() { + i := 0 + for { + select { + case <-done: + return + default: + fmt.Printf("\r%s %s", dim(frames[i%len(frames)]), label) + i++ + time.Sleep(80 * time.Millisecond) + } + } + }() + + err := fn() + close(done) + // \r + enough spaces to overwrite the longest spinner line, then + // \r again to put the cursor back at column 0 for whatever prints + // next — leaves no leftover spinner text behind. + fmt.Printf("\r%s\r", spacesLen(len(label)+4)) + return err +} + +func spacesLen(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = ' ' + } + return string(b) +} From 753e09895c0ec35e88b737348de5d728204d3b0d Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:19 +0100 Subject: [PATCH 02/19] feat: add box-drawing table output for query results Adds printTable() in table.go, used to render columns/rows as a real box-drawing table with a header. Replaces the fixed-width printf layout in ai query and oauth providers list, which blew up row widths on long UUIDs/emails. Cells are truncated with a trailing ellipsis past maxCellWidth instead of widening every row. When colors are disabled (piped output, NO_COLOR, non-TTY) it falls back to plain pipe-separated columns so scripts never see box-drawing characters mixed into captured data. Audit event types get semantic color: red for failed/reuse_detected/locked events, green for success/linked/unlocked. --- table.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 table.go diff --git a/table.go b/table.go new file mode 100644 index 0000000..1085e25 --- /dev/null +++ b/table.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "strings" +) + +// maxCellWidth caps how wide any single column gets — a full UUID or +// long email would otherwise blow up every row's width. Truncated +// values end in "…" so it's visually obvious they're cut, not just +// short data. +const maxCellWidth = 36 + +// printTable renders columns/rows as a real box-drawing table. Falls +// back to plain columns with no box characters when colors are +// disabled (piped output, NO_COLOR, non-TTY) — box-drawing characters +// in a script's captured output are just noise for whatever's +// parsing it, same reasoning as the existing color-disable behavior. +func printTable(columns []string, rows [][]string) { + if !colorsEnabled { + printPlainTable(columns, rows) + return + } + + cells := make([][]string, len(rows)+1) + cells[0] = truncateRow(columns) + for i, row := range rows { + cells[i+1] = truncateRow(row) + } + widths := columnWidths(cells) + + printBorder(widths, "┌", "┬", "┐") + printRow(cells[0], widths, true) + printBorder(widths, "├", "┼", "┤") + for i := 1; i < len(cells); i++ { + printRow(cells[i], widths, false) + } + printBorder(widths, "└", "┴", "┘") +} + +func printPlainTable(columns []string, rows [][]string) { + println_ := func(cells []string) { + printfLine(strings.Join(cells, " | ")) + } + println_(columns) + for _, row := range rows { + println_(row) + } +} + +func printfLine(s string) { + // Small indirection so this file has exactly one place that + // writes plain lines — kept separate from fmt.Println calls + // elsewhere so a future output-destination change (e.g. writing + // to a log file too) only touches one spot. + fmt.Println(s) +} + +func truncateRow(row []string) []string { + out := make([]string, len(row)) + for i, v := range row { + out[i] = truncateCell(v) + } + return out +} + +func truncateCell(v string) string { + if len(v) <= maxCellWidth { + return v + } + return v[:maxCellWidth-1] + "…" +} + +func columnWidths(cells [][]string) []int { + widths := make([]int, len(cells[0])) + for _, row := range cells { + for i, v := range row { + if len([]rune(v)) > widths[i] { + widths[i] = len([]rune(v)) + } + } + } + return widths +} + +func printBorder(widths []int, left, mid, right string) { + var b strings.Builder + b.WriteString(dim(left)) + for i, w := range widths { + b.WriteString(dim(strings.Repeat("─", w+2))) + if i < len(widths)-1 { + b.WriteString(dim(mid)) + } + } + b.WriteString(dim(right)) + fmt.Println(b.String()) +} + +func printRow(cells []string, widths []int, header bool) { + var b strings.Builder + b.WriteString(dim("│")) + for i, v := range cells { + padded := v + strings.Repeat(" ", widths[i]-len([]rune(v))) + if header { + padded = dim("\033[1m" + padded + "\033[22m") + } else { + padded = colorizeCell(v, padded) + } + b.WriteString(" " + padded + " ") + b.WriteString(dim("│")) + } + fmt.Println(b.String()) +} + +// colorizeCell gives audit event types a semantic color — the +// specific thing the earlier "any failed logins recently" test made +// obvious was missing: a wall of same-colored text makes it hard to +// spot the one row that matters. Anything else prints unchanged. +func colorizeCell(rawValue, padded string) string { + switch { + case strings.Contains(rawValue, "failed"), strings.Contains(rawValue, "reuse_detected"), strings.Contains(rawValue, "locked"): + return red(padded) + case strings.Contains(rawValue, "success"), strings.Contains(rawValue, "linked"), strings.Contains(rawValue, "unlocked"): + return green(padded) + default: + return padded + } +} From 7d2b44b129661e99d81a03a6f96b2f4874979286 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:21 +0100 Subject: [PATCH 03/19] feat: redact emails and IPs before sending data to AI providers Adds redactForSummary(), which replaces real email and IP values with stable per-result placeholders (email_1, ip_2, ...) before query results are handed to an AI provider for summarization. Audit data contains real emails and IPs, and an operator may not want that leaving their infrastructure even to a provider they trust. Placeholders are stable within one result set so repetition and clustering (email_3 appears 5 times) stay visible to the model, but actual values never leave. The full unredacted data is still printed to the terminal afterward; only the Summarize input goes through this. --- redact.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 redact.go diff --git a/redact.go b/redact.go new file mode 100644 index 0000000..bf9baec --- /dev/null +++ b/redact.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/crydensync/cryden/v2/ai" +) + +// redactForSummary builds the text sent to an AI provider for +// summarization, replacing email and IP values with stable +// per-result placeholders (email_1, ip_2, ...) rather than sending +// real PII to a third-party provider. +// +// This is a deliberate default, not a policy someone else already +// signed off on — flagging that plainly. Unlike the docs site's "Ask +// AI" (which only ever sends public documentation text), audit data +// contains real emails and IPs, and CrydenSync's own operator may not +// want that leaving their infrastructure even to a provider they +// trust for other things. Placeholders are stable WITHIN one result +// set, so repetition and clustering (e.g. "email_3 appears 5 times") +// are still visible to the model — the actual values just never are. +// +// The real, unredacted data is still what gets PRINTED to the +// terminal afterward (see printQueryResult in each caller) — only the +// text handed to Summarize goes through this. +func redactForSummary(result ai.QueryResult) string { + emailIdx, ipIdx := -1, -1 + for i, col := range result.Columns { + switch col { + case "email": + emailIdx = i + case "ip": + ipIdx = i + } + } + + emails := map[string]string{} + ips := map[string]string{} + + var b strings.Builder + for _, row := range result.Rows { + for i, v := range row { + col := result.Columns[i] + switch i { + case emailIdx: + v = pseudonym(emails, v, "email") + case ipIdx: + v = pseudonym(ips, v, "ip") + } + fmt.Fprintf(&b, "%s=%s ", col, v) + } + b.WriteString("\n") + } + return b.String() +} + +func pseudonym(seen map[string]string, value, label string) string { + if value == "" { + return value + } + if existing, ok := seen[value]; ok { + return existing + } + placeholder := fmt.Sprintf("%s_%d", label, len(seen)+1) + seen[value] = placeholder + return placeholder +} From b608298d4396ba3a5746d7ad91772c53c79109b0 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:29 +0100 Subject: [PATCH 04/19] feat: add interactive ai config wizard with read-only DB role setup Adds csax ai config, an interactive wizard covering everything ai query/ai logs/ai audit need: AI_PROVIDER, AI_MODEL, the env var name holding the API key (the key itself is never stored), and a genuinely read-only Postgres role. The read-only role was the biggest friction point in manual testing, so the wizard creates it directly via the admin DATABASE_URL rather than handing the user SQL to run by hand. Grants SELECT on exactly the tables ai.AllowedEntities covers, so the grants can never silently drift from what ExecuteQuery actually allows; also builds the matching READONLY_DATABASE_URL (with a Supabase pooler username quirk handled) and verifies it with a test connection. Role/password/identifier values are defensively escaped since Postgres protocol cannot parameterize DDL. Also defines shared interactive helpers (promptDefault, promptYesNo, appendEnvValues) reused by the oauth config wizard; appendEnvValues updates .env in place (0600) instead of replacing the whole file like cmd_config.go does, preserving existing DATABASE_URL/JWT_SECRET. --- cmd_ai_config.go | 277 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 cmd_ai_config.go diff --git a/cmd_ai_config.go b/cmd_ai_config.go new file mode 100644 index 0000000..002c306 --- /dev/null +++ b/cmd_ai_config.go @@ -0,0 +1,277 @@ +package main + +import ( + "bufio" + "database/sql" + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/crydensync/cryden/v2/ai" +) + +// cmdAIConfig interactively sets up everything `csax ai query`/`ai +// logs`/`ai audit` need: the AI provider, model, API key env var +// name, and — the part that caused the most friction in manual +// testing — a genuinely read-only Postgres role, created for the +// user rather than handed to them as a script to run by hand. +func cmdAIConfig(cfg csaxConfig) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("Setting up AI-assisted commands (ai query, ai logs, ai audit).") + fmt.Println() + + provider := promptDefault(reader, "AI provider (groq/openrouter)", "groq") + model := promptDefault(reader, "Model id", defaultModelFor(provider)) + apiKeyEnv := promptDefault(reader, "Env var name holding your API key (the key itself is never stored here)", strings.ToUpper(provider)+"_API_KEY") + + fmt.Println() + values := map[string]string{ + "AI_PROVIDER": provider, + "AI_MODEL": model, + "AI_API_KEY_ENV": apiKeyEnv, + } + + if promptYesNo(reader, "Set up a read-only database role now? (recommended — required for ai query/ai logs)", true) { + readonlyURL := setupReadonlyRole(reader, cfg) + if readonlyURL != "" { + values["READONLY_DATABASE_URL"] = readonlyURL + } + } else { + fmt.Println("Skipped. Run `csax ai config` again later, or set READONLY_DATABASE_URL by hand — see the README for the manual SQL.") + } + + appendEnvValues(reader, values) + fmt.Println() + fmt.Printf("Don't forget: export %s= before running ai commands.\n", apiKeyEnv) +} + +func defaultModelFor(provider string) string { + switch provider { + case "groq": + return "openai/gpt-oss-20b" + case "openrouter": + return "nvidia/nemotron-nano-9b-v2:free" + default: + return "" + } +} + +// setupReadonlyRole creates a Postgres role scoped to exactly the +// tables ai.AllowedEntities covers — the same allowlist ExecuteQuery +// enforces, so this list can never silently drift from what's +// actually queryable. Runs the SQL directly via the admin +// DATABASE_URL already in cfg, rather than handing the user a script +// to run by hand and hoping they get the connection right. +func setupReadonlyRole(reader *bufio.Reader, cfg csaxConfig) string { + roleName := promptDefault(reader, "Read-only role name", "csax_readonly") + password := promptDefault(reader, "Password for this role (leave blank to auto-generate)", "") + generated := password == "" + if generated { + password = generateSecret()[:24] + } + + db, err := sql.Open("postgres", cfg.DatabaseURL) + if err != nil { + fmt.Printf("could not open admin DB connection: %v\n", err) + return "" + } + defer db.Close() + + dbName, err := currentDatabaseName(db) + if err != nil { + fmt.Printf("could not determine current database name: %v\n", err) + return "" + } + + // Tables are taken from ai.AllowedEntities, not hardcoded here — + // if that allowlist ever changes, this wizard's grants + // automatically follow it instead of silently going stale. + tables := make([]string, 0, len(ai.AllowedEntities)) + for name := range ai.AllowedEntities { + tables = append(tables, name) + } + sort.Strings(tables) + + statements := []string{ + fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD %s", pqIdent(roleName), pqLiteral(password)), + fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s", pqIdent(dbName), pqIdent(roleName)), + fmt.Sprintf("GRANT USAGE ON SCHEMA public TO %s", pqIdent(roleName)), + } + for _, t := range tables { + statements = append(statements, fmt.Sprintf("GRANT SELECT ON %s TO %s", pqIdent(t), pqIdent(roleName))) + } + + fmt.Printf("Creating role %q with SELECT on: %s\n", roleName, strings.Join(tables, ", ")) + for _, stmt := range statements { + if _, err := db.Exec(stmt); err != nil { + // A role that already exists is a common, harmless case + // (re-running this wizard) — don't abort the whole setup + // over it, just note it and keep going with the grants. + if strings.Contains(err.Error(), "already exists") { + fmt.Printf(" (skipped, already exists: %s)\n", firstWords(stmt, 4)) + continue + } + fmt.Printf("failed running: %s\n error: %v\n", stmt, err) + return "" + } + } + fmt.Println("✔ Role created and granted.") + + readonlyURL, err := buildReadonlyURL(cfg.DatabaseURL, roleName, password) + if err != nil { + fmt.Printf("role was created, but couldn't build the connection string automatically: %v\n", err) + fmt.Println("Build it by hand: same host/port/database as DATABASE_URL, with this role's credentials.") + return "" + } + + testDB, err := sql.Open("postgres", readonlyURL) + if err == nil { + defer testDB.Close() + var pingErr error + withSpinner("Verifying connection...", func() error { + pingErr = testDB.Ping() + return nil // spinner just reports timing here, not success/failure + }) + if pingErr == nil { + fmt.Println("✔ Verified: the read-only connection works.") + } else { + fmt.Printf("⚠ Role created, but the test connection failed: %v\n", pingErr) + fmt.Println(" This can happen with some poolers (e.g. Supabase) needing a moment to recognize a new role — try `csax ai query` again shortly.") + } + } + + if generated { + fmt.Printf("Generated password: %s — this is only shown once, save it if you need it separately.\n", password) + } + + return readonlyURL +} + +// buildReadonlyURL constructs the read-only connection string from +// the existing admin DATABASE_URL, swapping in the new role's +// credentials. Handles ONE known provider-specific quirk explicitly +// (Supabase's pooler requires . as the username) +// rather than assuming every deployment works that way — a plain +// self-hosted Postgres, Neon, RDS, etc. all just get the plain role +// name. +func buildReadonlyURL(adminURL, roleName, password string) (string, error) { + u, err := url.Parse(adminURL) + if err != nil { + return "", err + } + + newUsername := roleName + if isSupabasePoolerHost(u.Hostname()) { + if existing := u.User.Username(); existing != "" { + if dot := strings.LastIndex(existing, "."); dot != -1 { + projectRef := existing[dot+1:] + newUsername = roleName + "." + projectRef + } + } + } + + u.User = url.UserPassword(newUsername, password) + return u.String(), nil +} + +func isSupabasePoolerHost(host string) bool { + return strings.Contains(host, "supabase.com") || strings.Contains(host, "supabase.co") +} + +func currentDatabaseName(db *sql.DB) (string, error) { + var name string + err := db.QueryRow("SELECT current_database()").Scan(&name) + return name, err +} + +// pqIdent and pqLiteral do minimal, defensive escaping for values +// interpolated into DDL statements that Postgres' protocol can't +// parameterize (role/table names, CREATE ROLE's password clause). +// This is wizard input the person is typing about their own +// database, not attacker-controlled — but escaping costs nothing and +// avoids a broken role name silently becoming a SQL syntax error or +// worse. +func pqIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +func pqLiteral(s string) string { + return `'` + strings.ReplaceAll(s, `'`, `''`) + `'` +} + +func firstWords(s string, n int) string { + words := strings.Fields(s) + if len(words) > n { + words = words[:n] + } + return strings.Join(words, " ") + "..." +} + +func promptDefault(reader *bufio.Reader, label, def string) string { + if def != "" { + fmt.Printf("%s [%s]: ", label, def) + } else { + fmt.Printf("%s: ", label) + } + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} + +func promptYesNo(reader *bufio.Reader, label string, def bool) bool { + suffix := "[Y/n]" + if !def { + suffix = "[y/N]" + } + fmt.Printf("%s %s: ", label, suffix) + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(strings.ToLower(line)) + if line == "" { + return def + } + return line == "y" || line == "yes" +} + +// appendEnvValues writes/updates .env with the given key-value pairs, +// preserving whatever's already there — cmd_config.go's writer +// replaces the whole file, which would destroy DATABASE_URL/JWT_SECRET +// if reused here, so this reads first and only touches matching keys. +func appendEnvValues(reader *bufio.Reader, values map[string]string) { + existing := map[string]string{} + var order []string + if data, err := os.ReadFile(".env"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if eq := strings.Index(line, "="); eq != -1 { + key := line[:eq] + existing[key] = line[eq+1:] + order = append(order, key) + } + } + } + for k, v := range values { + if _, ok := existing[k]; !ok { + order = append(order, k) + } + existing[k] = v + } + + var b strings.Builder + for _, k := range order { + fmt.Fprintf(&b, "%s=%s\n", k, existing[k]) + } + if err := os.WriteFile(".env", []byte(b.String()), 0600); err != nil { + fmt.Printf("failed to write .env: %v\n", err) + os.Exit(1) + } + fmt.Println("✔ Updated .env") +} From 3f152a4d2bbaf82a6017521349699682cfc956f2 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:31 +0100 Subject: [PATCH 05/19] feat: add ai ask command for direct natural-language questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds csax ai ask "" (aliased as csax audit ask), a direct-question variant of ai query: it runs the same ExecuteQuery/SafeQueryStore path and answers in plain language instead of just printing a table. Unlike ai logs this deliberately does NOT force the entity to audit_events — a real operator question ("anything weird with devray@example.com this week?") may legitimately need users or sessions data. The question text and redacted results are sent to the provider for the narrative answer; unsafe intents get the same ErrUnsafeQueryIntent treatment as ai query. --- cmd_ai_ask.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 cmd_ai_ask.go diff --git a/cmd_ai_ask.go b/cmd_ai_ask.go new file mode 100644 index 0000000..c5cc22c --- /dev/null +++ b/cmd_ai_ask.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// cmdAIAsk implements `csax ai ask ""` (aliased as `csax +// audit ask`). Unlike `ai logs`, this does NOT force Entity to +// audit_events — a real operator question ("anything weird with +// devray@example.com this week?") may need to look at users or +// sessions too, not just the audit trail. Same ExecuteQuery/ +// SafeQueryStore path as `ai query`, phrased as a direct question +// with a narrative answer instead of a raw table. +func cmdAIAsk(cfg csaxConfig, question string) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + var result ai.QueryResult + err := withSpinner("Thinking...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, provider, question) + return qerr + }) + if err != nil { + if errors.Is(err, ai.ErrUnsafeQueryIntent) { + fmt.Println(red("That question can't be translated into an allowed query.")) + fmt.Println(dim("Try rephrasing, or ask about one of: users, sessions, audit_events.")) + os.Exit(1) + } + fmt.Println(red("Couldn't answer that: " + err.Error())) + os.Exit(1) + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No matching data found.")) + return + } + + var answer string + answerErr := withSpinner("Summarizing...", func() error { + var aerr error + answer, aerr = provider.Summarize(context.Background(), + "You are answering a system administrator's direct question about their own application's data. "+ + "Some identifiers below are placeholders (email_1, ip_2, etc.) standing in for real values — refer "+ + "to them exactly as given, never invent a real-looking email or IP. Answer the question directly in "+ + "2-4 plain-language sentences, using only what's in the data given. If the data doesn't actually "+ + "answer the question, say so rather than guessing.", + question+"\n\nData:\n"+redactForSummary(result)) + return aerr + }) + if answerErr != nil { + fmt.Println(yellow("(could not generate an answer: " + answerErr.Error() + ")")) + } else { + fmt.Println(answer) + fmt.Println() + } + + printQueryResult(result, false) +} From 3f604ec8cae7aeb4886fab1a0c63ed9b92542b24 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:38 +0100 Subject: [PATCH 06/19] feat: add ai anomalies scan for credential-stuffing and abuse patterns Adds csax ai anomalies scan [--since 24h], a fixed-prompt variant of ai logs: it runs the same auditOnlyProvider/ExecuteQuery path with a no-input prompt looking for repeated failed logins, token reuse detections, new/unusual login locations, or signup clusters. Implemented as a thin wrapper over the ai logs code path rather than a separate implementation so the two commands cannot quietly drift apart in behavior. Results are redacted before summarization like ai logs, and the scan window is configurable via --since. --- cmd_ai_anomalies.go | 67 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 cmd_ai_anomalies.go diff --git a/cmd_ai_anomalies.go b/cmd_ai_anomalies.go new file mode 100644 index 0000000..634873f --- /dev/null +++ b/cmd_ai_anomalies.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// cmdAIAnomaliesScan is `ai logs` pre-run with a fixed, no-input +// prompt — deliberately implemented as a thin wrapper around the same +// auditOnlyProvider/ExecuteQuery path rather than a separate code +// path, so the two commands can never quietly drift apart in +// behavior. +func cmdAIAnomaliesScan(cfg csaxConfig, since string) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + auditProvider := &auditOnlyProvider{inner: provider} + + naturalLanguage := fmt.Sprintf( + "unusual or suspicious activity in the last %s — repeated failed logins, token reuse detections, logins from new or unusual locations, or clusters of signups from the same source", + since, + ) + var result ai.QueryResult + err := withSpinner("Scanning for anomalies...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, auditProvider, naturalLanguage) + return qerr + }) + if err != nil { + fmt.Println(red("Anomaly scan failed: " + err.Error())) + os.Exit(1) + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No events found in that window.")) + return + } + + var summary string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + summary, serr = provider.Summarize(context.Background(), + "You are scanning audit log events for a system administrator, looking specifically for signs of "+ + "credential stuffing, account takeover attempts, or abuse. Some identifiers below are placeholders "+ + "(email_1, ip_2, etc.) standing in for real values — refer to them exactly as given, never invent a "+ + "real-looking email or IP. Summarize what you actually see in 2-4 plain-language sentences. Only "+ + "describe patterns present in the data given — never invent detail. If nothing looks concerning, "+ + "say so plainly rather than manufacturing a finding.", + redactForSummary(result)) + return serr + }) + if summarizeErr != nil { + fmt.Println(yellow("(could not generate a summary: " + summarizeErr.Error() + ")")) + } else { + fmt.Println(summary) + fmt.Println() + } + + fmt.Println(dim("Run `csax ai logs \"...\"` for detail on any of these, or `csax ai query` for user-level data.")) + fmt.Println() + printQueryResult(result, false) +} From e43f93acf50705388406db2f1fd69ad52b922e7f Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:46 +0100 Subject: [PATCH 07/19] feat: add interactive oauth config wizard for provider credentials Adds csax oauth config, an interactive wizard that sets BASE_URL/FRONTEND_URL plus Google and GitHub client credentials in one pass, and prints the exact callback URLs to register in each provider console. The prompts are deliberately explicit about BASE_URL vs FRONTEND_URL because that exact mixup broke a real deployment during testing: BASE_URL is the BACKEND public URL (where the /api/oauth/.../callback route lives), not the frontend domain. Adds the FRONTEND_URL field to csaxConfig so the wizard and future commands can read the real value. Also adds printCallbackURLs(), which cmd_oauth providers add reuses, and capitalizeFirst() as a small formatting helper. --- cmd_oauth_config.go | 65 +++++++++++++++++++++++++++++++++++++++++++++ config.go | 2 ++ 2 files changed, 67 insertions(+) create mode 100644 cmd_oauth_config.go diff --git a/cmd_oauth_config.go b/cmd_oauth_config.go new file mode 100644 index 0000000..7773f21 --- /dev/null +++ b/cmd_oauth_config.go @@ -0,0 +1,65 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// cmdOAuthConfig interactively sets up OAuth env vars. Deliberately +// explicit about the BASE_URL vs FRONTEND_URL distinction — this is +// exactly the mixup that broke a real deployment during testing +// (BASE_URL was set to the frontend's Vercel URL instead of the +// backend's own URL, since that's where the callback ROUTE actually +// lives), so the prompts spell out which is which rather than +// assuming it's obvious. +func cmdOAuthConfig(cfg csaxConfig) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("Setting up OAuth (Google/GitHub login).") + fmt.Println() + fmt.Println("BASE_URL is your BACKEND's own public URL — the callback route") + fmt.Println("(/api/oauth/.../callback) is a route on YOUR SERVER, not on your") + fmt.Println("frontend. If your frontend and backend are on different domains") + fmt.Println("(e.g. Vercel + Railway), BASE_URL is the Railway one.") + fmt.Println() + + baseURL := promptDefault(reader, "BASE_URL (your backend's own URL)", cfg.BaseURL) + frontendURL := promptDefault(reader, "FRONTEND_URL (where the browser lands after login)", cfg.FrontendURL) + + values := map[string]string{ + "BASE_URL": baseURL, + "FRONTEND_URL": frontendURL, + } + + if promptYesNo(reader, "Configure Google?", true) { + values["GOOGLE_CLIENT_ID"] = promptDefault(reader, "Google client ID", "") + values["GOOGLE_CLIENT_SECRET"] = promptDefault(reader, "Google client secret", "") + printCallbackURLs(baseURL, "google") + } + if promptYesNo(reader, "Configure GitHub?", true) { + values["GITHUB_CLIENT_ID"] = promptDefault(reader, "GitHub client ID", "") + values["GITHUB_CLIENT_SECRET"] = promptDefault(reader, "GitHub client secret", "") + printCallbackURLs(baseURL, "github") + } + + appendEnvValues(reader, values) + fmt.Println() + fmt.Println("Register the callback URLs printed above in each provider's console before testing.") + fmt.Println("Run `csax oauth test ` afterward to confirm it's reachable.") +} + +func printCallbackURLs(baseURL, provider string) { + base := strings.TrimRight(baseURL, "/") + fmt.Printf("\n Register these in %s's console:\n", capitalizeFirst(provider)) + fmt.Printf(" %s/api/oauth/%s/callback\n", base, provider) + fmt.Printf(" %s/api/oauth/%s/link/callback\n\n", base, provider) +} + +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/config.go b/config.go index acfcb12..ac0f4d2 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,7 @@ type csaxConfig struct { // so `csax oauth test` checks the actual values production uses, // not a separate csax-only copy that could drift out of sync. BaseURL string + FrontendURL string GoogleClientID string GoogleClientSecret string GitHubClientID string @@ -43,6 +44,7 @@ func loadConfig() (csaxConfig, error) { MigrationsDir: os.Getenv("MIGRATIONS_DIR"), BaseURL: strings.TrimRight(os.Getenv("BASE_URL"), "/"), + FrontendURL: strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), From 83c663eaf5b4fc74ec520cb2dcbf10eea62b9879 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:52 +0100 Subject: [PATCH 08/19] feat: add oauth providers add and polish oauth list/test output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds csax oauth providers add , a narrow interactive path to configure one provider (client ID/secret + callback URLs) for someone who already has BASE_URL/FRONTEND_URL set — faster than rerunning the full oauth config wizard. Also upgrades the existing commands: providers list now renders through printTable() with truncated cells instead of fixed-width printf (long UUIDs/emails no longer blow up the layout), and oauth test wraps the endpoint reachability check in withSpinner() so the network round trip does not look hung. --- cmd_oauth.go | 53 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/cmd_oauth.go b/cmd_oauth.go index 9e4e708..f1ae8e4 100644 --- a/cmd_oauth.go +++ b/cmd_oauth.go @@ -1,10 +1,12 @@ package main import ( + "bufio" "encoding/json" "fmt" "net/http" "os" + "strings" "time" ) @@ -58,17 +60,13 @@ func cmdOAuthProvidersList(cfg csaxConfig, jsonOutput bool) { return } - fmt.Printf("%-10s %-12s %-16s %-18s\n", "PROVIDER", "CONFIGURED", "CLIENT ID SET", "CLIENT SECRET SET") + columns := []string{"PROVIDER", "CONFIGURED", "CLIENT ID SET", "CLIENT SECRET SET"} + var rows [][]string for _, p := range providers { configured := p.ClientIDSet && p.ClientSecretSet - configuredStr := yesNo(configured) - if configured { - configuredStr = green(configuredStr) - } else { - configuredStr = dim(configuredStr) - } - fmt.Printf("%-10s %-12s %-16s %-18s\n", p.Name, configuredStr, yesNo(p.ClientIDSet), yesNo(p.ClientSecretSet)) + rows = append(rows, []string{p.Name, yesNo(configured), yesNo(p.ClientIDSet), yesNo(p.ClientSecretSet)}) } + printTable(columns, rows) if cfg.BaseURL == "" { fmt.Println(yellow("\nwarning: BASE_URL is not set — provider redirect URIs cannot be computed correctly")) } @@ -113,9 +111,14 @@ func cmdOAuthTest(cfg csaxConfig, providerName string) { } client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(p.discoveryCheckAddr) - if err != nil { - fmt.Println(red("✗") + " could not reach " + p.Name + "'s endpoint: " + err.Error()) + var resp *http.Response + reachErr := withSpinner("Checking "+p.Name+"'s endpoint...", func() error { + var rerr error + resp, rerr = client.Get(p.discoveryCheckAddr) + return rerr + }) + if reachErr != nil { + fmt.Println(red("✗") + " could not reach " + p.Name + "'s endpoint: " + reachErr.Error()) ok = false } else { resp.Body.Close() @@ -135,6 +138,34 @@ func cmdOAuthTest(cfg csaxConfig, providerName string) { } } +// cmdOAuthProvidersAdd configures ONE named provider — a narrower, +// faster alternative to the full `csax oauth config` wizard for +// someone who already has BASE_URL/FRONTEND_URL set and just wants to +// add a provider's credentials. +func cmdOAuthProvidersAdd(cfg csaxConfig, providerName string) { + if providerName != "google" && providerName != "github" { + fmt.Println(red("unknown provider: " + providerName + " (expected: google, github)")) + os.Exit(1) + } + if cfg.BaseURL == "" { + fmt.Println(yellow("BASE_URL is not set yet — run `csax oauth config` first, or set it by hand before adding a provider.")) + os.Exit(1) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("Adding %s.\n", providerName) + clientID := promptDefault(reader, providerName+" client ID", "") + clientSecret := promptDefault(reader, providerName+" client secret", "") + + prefix := strings.ToUpper(providerName) + appendEnvValues(reader, map[string]string{ + prefix + "_CLIENT_ID": clientID, + prefix + "_CLIENT_SECRET": clientSecret, + }) + printCallbackURLs(cfg.BaseURL, providerName) + fmt.Println("Run `csax oauth test " + providerName + "` to confirm it's reachable.") +} + func yesNo(b bool) string { if b { return "yes" From bbb2feeda00831cbf1ba9107f25ce7bcfad94da8 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:19:53 +0100 Subject: [PATCH 09/19] feat: add oauth users get and oauth unlink admin commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds csax oauth users get [--json] (lists which providers an account has linked) and csax oauth unlink --provider (force-unlinks a provider, the admin escape hatch in the same spirit as users unlock). oauth_identities has no engine store method exposed for these yet, so they query the known Postgres schema directly (SELECT by user_id / DELETE by user_id+provider) — the same direct-SQL pattern already used by users list, stats, and audit search, with no engine change needed. users get supports --json for scripting; unlink reports no-op cleanly when the account has no such provider linked. --- cmd_users.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/cmd_users.go b/cmd_users.go index 26ee3f8..7db8b72 100644 --- a/cmd_users.go +++ b/cmd_users.go @@ -54,6 +54,99 @@ func cmdUsersGet(db *sql.DB, email string, jsonOutput bool) { fmt.Printf("Sessions: %d active\n", len(sessions)) } +// oauthIdentityRow mirrors one row of oauth_identities — kept local +// to this file since it's a read-only query result, not a store type. +type oauthIdentityRow struct { + ID string + Provider string + ExternalID string + CreatedAt string +} + +// cmdOAuthUsersGet lists which providers an account has linked. +// oauth_identities has no engine store method exposed for this yet +// (see the eight-gaps-style facade decision) — same pattern as +// cmdStats: direct SQL against the known, documented schema, not an +// engine change. +func cmdOAuthUsersGet(db *sql.DB, email string, jsonOutput bool) { + if email == "" { + fmt.Println("usage: csax oauth users get [--json]") + os.Exit(1) + } + + var userID string + err := db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err != nil { + fmt.Println(red("failed to find user: " + err.Error())) + os.Exit(1) + } + + rows, err := db.Query(` + SELECT id, provider, external_id, created_at + FROM oauth_identities WHERE user_id = $1 + ORDER BY created_at ASC + `, userID) + if err != nil { + fmt.Println(red("failed to query oauth_identities: " + err.Error())) + os.Exit(1) + } + defer rows.Close() + + var identities []oauthIdentityRow + for rows.Next() { + var r oauthIdentityRow + if err := rows.Scan(&r.ID, &r.Provider, &r.ExternalID, &r.CreatedAt); err != nil { + fmt.Println(red("failed reading row: " + err.Error())) + os.Exit(1) + } + identities = append(identities, r) + } + + if jsonOutput { + out := map[string]any{"email": email, "user_id": userID, "linked_providers": identities} + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + return + } + + fmt.Printf("%s (user_id: %s)\n", email, userID) + if len(identities) == 0 { + fmt.Println(dim(" no linked providers")) + return + } + for _, id := range identities { + fmt.Printf(" %-8s — linked %s\n", id.Provider, id.CreatedAt) + } +} + +// cmdOAuthUnlink force-unlinks one provider from an account — the +// admin escape hatch, same spirit as `csax users unlock`. +func cmdOAuthUnlink(db *sql.DB, email, provider string) { + if email == "" || provider == "" { + fmt.Println("usage: csax oauth unlink --provider ") + os.Exit(1) + } + + var userID string + err := db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err != nil { + fmt.Println(red("failed to find user: " + err.Error())) + os.Exit(1) + } + + result, err := db.Exec(`DELETE FROM oauth_identities WHERE user_id = $1 AND provider = $2`, userID, provider) + if err != nil { + fmt.Println(red("failed to unlink: " + err.Error())) + os.Exit(1) + } + affected, _ := result.RowsAffected() + if affected == 0 { + fmt.Println(yellow(fmt.Sprintf("%s has no linked %s account — nothing to do.", email, provider))) + return + } + fmt.Println(green(fmt.Sprintf("✔ Unlinked %s from %s", provider, email))) +} + func cmdUsersUnlock(db *sql.DB, email string) { if email == "" { fmt.Println("usage: csax users unlock ") From c1c05b8fcf1a543f213690e4b837cf9f99b4c85c Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:14 +0100 Subject: [PATCH 10/19] fix: make ai logs truly audit-only and stop trusting the model entity choice Previously ParseQueryIntent overrode Entity to audit_events but left the Aggregate/GroupBy the model chose intact, so a vague prompt like "any failed logins recently" could come back as a single count row instead of the actual log entries ai logs exists to show. Now the entity is forced to audit_events as before, and Aggregate/GroupBy are cleared so the command always returns real rows for review. It also records the original entity the model chose, so cmdAILogs can warn when the question was clearly about something else (e.g. "show me all users") instead of silently returning audit events that were never what the person asked for. --- cmd_ai_logs.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/cmd_ai_logs.go b/cmd_ai_logs.go index d244719..cd6692a 100644 --- a/cmd_ai_logs.go +++ b/cmd_ai_logs.go @@ -18,14 +18,28 @@ import ( // validated path, not two. type auditOnlyProvider struct { inner *llmProvider + // originalEntity records what the model actually chose, before + // being overridden below — lets cmdAILogs warn the person when + // they've asked a question this command was never going to + // answer (e.g. "show me all users" silently returning audit + // events instead, with no indication that's not what they meant). + originalEntity string } -func (p auditOnlyProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { +func (p *auditOnlyProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { intent, err := p.inner.ParseQueryIntent(ctx, naturalLanguage) if err != nil { return ai.QueryIntent{}, err } + p.originalEntity = intent.Entity intent.Entity = "audit_events" // this command is audit-only by definition — never trust the model's entity choice here + // Force real rows, never an aggregate — `ai logs` exists to show + // actual events for review. Left to the model, a vague prompt + // like "any failed logins recently" can get interpreted as + // Aggregate: "count", which produces a single useless number + // instead of the log entries the command is actually for. + intent.Aggregate = "" + intent.GroupBy = "" return intent, nil } From 6cfa5f2cc93db36f6900344ba9dff1d605786594 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:20 +0100 Subject: [PATCH 11/19] feat: add spinner and PII redaction to ai logs summarization Wraps the ExecuteQuery and Summarize round trips in withSpinner() so the audit search does not look hung during provider calls, and sends redactForSummary() output (placeholder email_1/ip_2 values) to the provider instead of raw rows, with an updated system prompt telling the model to treat placeholders exactly as given. Replaces the old formatRowsForSummary helper, which built the same text but without redaction. --- cmd_ai_logs.go | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/cmd_ai_logs.go b/cmd_ai_logs.go index cd6692a..40274b0 100644 --- a/cmd_ai_logs.go +++ b/cmd_ai_logs.go @@ -54,24 +54,42 @@ func cmdAILogs(cfg csaxConfig, naturalLanguage string) { defer readonlyDB.Close() store := postgres.NewSafeQueryStore(readonlyDB) - result, err := ai.ExecuteQuery(context.Background(), store, auditOnlyProvider{inner: provider}, naturalLanguage) + auditProvider := &auditOnlyProvider{inner: provider} + var result ai.QueryResult + err := withSpinner("Searching audit log...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, auditProvider, naturalLanguage) + return qerr + }) if err != nil { fmt.Println(red("Log search failed: " + err.Error())) os.Exit(1) } + if auditProvider.originalEntity != "" && auditProvider.originalEntity != "audit_events" { + fmt.Println(yellow(fmt.Sprintf("(Note: ai logs only searches audit events — your question looked like it was about %s. Try `csax ai query` for that.)", auditProvider.originalEntity))) + fmt.Println() + } + if len(result.Rows) == 0 { fmt.Println(dim("No matching audit events.")) return } - summary, err := provider.Summarize(context.Background(), - "You summarize a list of audit log events for a system administrator in 2-3 plain-language sentences. "+ - "Only describe patterns you can see in the data given. Never suggest the administrator take an action you're not certain about; "+ - "if a corrective action seems relevant, phrase it as a suggestion, never as something already done.", - formatRowsForSummary(result)) - if err != nil { - fmt.Println(yellow("(could not generate a summary: " + err.Error() + ")")) + var summary string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + summary, serr = provider.Summarize(context.Background(), + "You summarize a list of audit log events for a system administrator in 2-3 plain-language sentences. "+ + "Some identifiers below are placeholders (email_1, ip_2, etc.) standing in for real values — refer to "+ + "them exactly as given, never invent a real-looking email or IP. Only describe patterns you can see "+ + "in the data given. Never suggest the administrator take an action you're not certain about; "+ + "if a corrective action seems relevant, phrase it as a suggestion, never as something already done.", + redactForSummary(result)) + return serr + }) + if summarizeErr != nil { + fmt.Println(yellow("(could not generate a summary: " + summarizeErr.Error() + ")")) } else { fmt.Println(summary) fmt.Println() @@ -79,14 +97,3 @@ func cmdAILogs(cfg csaxConfig, naturalLanguage string) { printQueryResult(result, false) } - -func formatRowsForSummary(result ai.QueryResult) string { - out := "" - for _, row := range result.Rows { - for i, v := range row { - out += result.Columns[i] + "=" + v + " " - } - out += "\n" - } - return out -} From f00886535c644dbb30b6478e05d46726a9d1690d Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:21 +0100 Subject: [PATCH 12/19] feat: show spinner while ai audit prioritizes findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the provider Summarize call in cmdAIAudit with withSpinner() so the fixed-checklist narrative generation does not look hung — same network-round-trip coverage as the other ai commands. --- cmd_ai_audit.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cmd_ai_audit.go b/cmd_ai_audit.go index 7b6a17f..379248a 100644 --- a/cmd_ai_audit.go +++ b/cmd_ai_audit.go @@ -83,12 +83,17 @@ func cmdAIAudit(cfg csaxConfig) { if err != nil || flagged == 0 { return } - narrative, err := provider.Summarize(context.Background(), - "You are prioritizing a fixed list of security configuration findings for a system administrator. "+ - "You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+ - "which flagged item to fix first and why, in plain language.", - formatFindingsForSummary(findings)) - if err == nil && narrative != "" { + var narrative string + summarizeErr := withSpinner("Summarizing...", func() error { + var serr error + narrative, serr = provider.Summarize(context.Background(), + "You are prioritizing a fixed list of security configuration findings for a system administrator. "+ + "You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+ + "which flagged item to fix first and why, in plain language.", + formatFindingsForSummary(findings)) + return serr + }) + if summarizeErr == nil && narrative != "" { fmt.Println("\n" + narrative) } } From 2a520ca9a6838129f26455b0731e9786f2c432ed Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:27 +0100 Subject: [PATCH 13/19] feat: add spinner and table output to ai query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the ExecuteQuery call in withSpinner() so query intent/planning does not look hung during provider calls, and renders result rows through printTable() instead of the old fixed-width printf loop — long UUIDs and emails are truncated with an ellipsis instead of widening every row. --- cmd_ai_query.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/cmd_ai_query.go b/cmd_ai_query.go index a615334..0cbc1cc 100644 --- a/cmd_ai_query.go +++ b/cmd_ai_query.go @@ -22,7 +22,12 @@ func cmdAIQuery(cfg csaxConfig, naturalLanguage string, jsonOutput bool) { defer readonlyDB.Close() store := postgres.NewSafeQueryStore(readonlyDB) - result, err := ai.ExecuteQuery(context.Background(), store, provider, naturalLanguage) + var result ai.QueryResult + err := withSpinner("Thinking...", func() error { + var qerr error + result, qerr = ai.ExecuteQuery(context.Background(), store, provider, naturalLanguage) + return qerr + }) if err != nil { if errors.Is(err, ai.ErrUnsafeQueryIntent) { // Deliberately does NOT echo the model's raw output or @@ -52,16 +57,7 @@ func printQueryResult(result ai.QueryResult, jsonOutput bool) { fmt.Println(dim("No rows.")) return } - for _, col := range result.Columns { - fmt.Printf("%-24s", col) - } - fmt.Println() - for _, row := range result.Rows { - for _, v := range row { - fmt.Printf("%-24s", v) - } - fmt.Println() - } + printTable(result.Columns, result.Rows) fmt.Printf("\n%d row(s).\n", len(result.Rows)) } From 7a118a07a54553e915db6b4759d6a4b834437a4c Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:29 +0100 Subject: [PATCH 14/19] fix: respect NO_COLOR for explicit plain-output opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously colors were decided purely by terminal detection, so a TTY with NO_COLOR set (https://no-color.org) still got escape codes. That breaks scripts that explicitly set NO_COLOR to force plain output, and the project now documents NO_COLOR support. colorsEnabled is now isTerminal() && NO_COLOR unset — any non-empty NO_COLOR disables color regardless of terminal detection, since it is an explicit preference overriding csax’s own guess. All existing color gating (table output, spinner, status colors) inherits this automatically. --- colors.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/colors.go b/colors.go index e515a9d..4b575be 100644 --- a/colors.go +++ b/colors.go @@ -6,9 +6,12 @@ import "os" // stdlib-only philosophy. Colors are skipped automatically when // output isn't a real terminal (e.g. piped to a file or another // program), so scripting against csax output never sees raw escape -// codes mixed into the data. +// codes mixed into the data. Also respects NO_COLOR (see +// https://no-color.org) — any non-empty value disables color, +// regardless of terminal detection, since that's the person's +// explicit preference overriding csax's own guess. -var colorsEnabled = isTerminal() +var colorsEnabled = isTerminal() && os.Getenv("NO_COLOR") == "" func isTerminal() bool { fi, err := os.Stdout.Stat() From 5732f792ef1d1e473c0c3e3071426c1bb882429a Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:52 +0100 Subject: [PATCH 15/19] fix: report dev version and correct target cryden version csaxVersion previously claimed v0.1.0, a version that was never actually tagged, so csax version / health lied about what was running. It is now "dev" until a real release tag is cut. targetCrydenVersion is bumped from v2.0.0 to v2.1.0 to match the cryden/v2 module version actually used in go.mod, so the health check no longer warns about a mismatch against the dependency in use. --- cmd_health.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd_health.go b/cmd_health.go index 178950c..fc0b6cf 100644 --- a/cmd_health.go +++ b/cmd_health.go @@ -5,8 +5,8 @@ import ( "fmt" ) -const csaxVersion = "v0.1.0" -const targetCrydenVersion = "cryden/v2 v2.0.0" +const csaxVersion = "dev" // set to the real tag (e.g. "v1.1.0") when this release is actually cut +const targetCrydenVersion = "cryden/v2 v2.1.0" func cmdHealth(db *sql.DB) { if err := db.Ping(); err != nil { From bc6fa3b3514cfaf05ca555ba966288e941d50161 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:20:53 +0100 Subject: [PATCH 16/19] feat: add doctor command aggregating all health checks Adds csax doctor, a one-shot health check covering the database (ping + migration table presence), AI-assisted command configuration (the same runAuditChecklist findings health-related ai audit uses, plus provider setup), and an OAuth configuration summary. It reuses the existing check functions (db ping, runAuditChecklist, oauthProviders, newLLMProvider) rather than re-implementing them, so there is one source of truth for each check instead of two that could drift. checkMigration() reports missing tables with a concrete fix hint (run csax migrate up, or apply the 0002_oauth_identities migration). --- cmd_doctor.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 cmd_doctor.go diff --git a/cmd_doctor.go b/cmd_doctor.go new file mode 100644 index 0000000..82441bd --- /dev/null +++ b/cmd_doctor.go @@ -0,0 +1,73 @@ +package main + +import ( + "database/sql" + "fmt" +) + +// cmdDoctor runs every health/config check csax knows how to run, in +// one command. Reuses the same underlying checks as `csax health` and +// `csax ai audit` rather than re-implementing them — one source of +// truth for "is JWT_SECRET long enough", not two that could drift. +func cmdDoctor(cfg csaxConfig, db *sql.DB) { + fmt.Println("csax doctor") + fmt.Println() + + fmt.Println("Database") + if err := db.Ping(); err != nil { + fmt.Printf(" ✗ unreachable: %v\n", err) + } else { + fmt.Println(" ✔ reachable") + checkMigration(db, "csax_migrations", "run `csax migrate up`") + checkMigration(db, "oauth_identities", "run the 0002_oauth_identities migration (see README)") + } + + fmt.Println() + fmt.Println("AI-assisted commands") + for _, f := range runAuditChecklist(cfg) { + icon, color := "✓", green + if f.Severity == "HIGH" { + icon, color = "✗", red + } else if f.Severity == "MEDIUM" { + icon, color = "⚠", yellow + } + fmt.Printf(" %s %s\n", color(icon), f.Message) + } + if _, err := newLLMProvider(cfg); err != nil { + fmt.Printf(" ⚠ %v (run `csax ai config`)\n", err) + } else { + fmt.Println(" ✔ AI provider configured") + } + + fmt.Println() + fmt.Println("OAuth") + anyConfigured := false + for _, p := range oauthProviders(cfg) { + configured := p.ClientIDSet && p.ClientSecretSet + if configured { + anyConfigured = true + fmt.Printf(" ✔ %s configured\n", p.Name) + } + } + if !anyConfigured { + fmt.Println(" ⚠ no providers configured (optional — run `csax oauth config` to add one)") + } + if cfg.BaseURL == "" && anyConfigured { + fmt.Println(" ✗ BASE_URL is not set, but a provider is configured — OAuth callback URLs cannot be built") + } + + fmt.Println() +} + +// checkMigration reports whether a table exists — a cheap, reliable +// proxy for "has this migration been run" without needing a real +// migration-tracking scheme for every possible table. +func checkMigration(db *sql.DB, table, fixHint string) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = $1`, table).Scan(&count) + if err != nil || count == 0 { + fmt.Printf(" ✗ table %q missing — %s\n", table, fixHint) + return + } + fmt.Printf(" ✔ table %q present\n", table) +} From 32a42a0916c1430c4ef2589abb3b8bfac866ce40 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:21:01 +0100 Subject: [PATCH 17/19] feat: wire new ai/oauth/doctor commands into the CLI Adds CLI dispatch and usage text for the new commands: ai config, ai anomalies scan, ai ask, audit ask (alias), oauth config, oauth providers add, oauth users get, oauth unlink, and doctor. The oauth providers subcommand is restructured to a list|add switch, and usage() lists every command with the interactive setup hint. Also prints a small ASCII logo when the binary runs with no arguments on a colored terminal, and the bare usage line for audit now mentions ask. The audit ask alias is handled inside the audit switch (Go forbids duplicate switch cases, so it cannot be a separate top-level case). --- main.go | 121 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 14 deletions(-) diff --git a/main.go b/main.go index d498629..0e7e0a8 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,12 @@ import ( "github.com/crydensync/cryden/v2" ) +const logo = ` + ▄████████▄ ▄▄▄ + █ ██████▀▘ ▄▄▄▄▄ csax — CrydenSync admin CLI + █ ▀▀▀▀▘ ▀▀▀▀▀▘ self-hosted auth, owned by you +` + func usage() { fmt.Println(`csax — CrydenSync admin CLI @@ -24,21 +30,34 @@ Usage: csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] - csax oauth providers list [--json] + csax audit ask "" + csax oauth providers list [--json] | add csax oauth test + csax oauth users get [--json] + csax oauth unlink --provider + csax oauth config csax ai query "" [--json] csax ai logs "" + csax ai anomalies scan [--since 24h] + csax ai ask "" csax ai audit + csax ai config + csax doctor csax stats csax health csax version oauth and ai commands are optional — see README for the env vars each -one needs. Run any command with no further args for its specific usage.`) +one needs, or run ` + "`csax ai config`" + ` / ` + "`csax oauth config`" + ` for an +interactive setup. Run any command with no further args for its +specific usage.`) } func main() { if len(os.Args) < 2 { + if colorsEnabled { + fmt.Println(dim(logo)) + } usage() os.Exit(1) } @@ -159,7 +178,7 @@ func main() { db := mustConnect(cfg) defer db.Close() if len(os.Args) < 3 { - fmt.Println("usage: csax audit tail|search [args]") + fmt.Println(`usage: csax audit tail|search [args] | ask ""`) os.Exit(1) } switch os.Args[2] { @@ -175,42 +194,89 @@ func main() { limit := fs.Int("limit", 20, "max events to show") fs.Parse(os.Args[3:]) cmdAuditSearch(db, *event, *limit) + case "ask": + // alias for `ai ask` — same underlying command, just + // phrased the way someone thinking "ask about my audit + // trail" is more likely to type it. db was already + // opened above for tail/search but isn't needed here — + // cmdAIAsk opens its own read-only connection. + if len(os.Args) < 4 { + fmt.Println(`usage: csax audit ask ""`) + os.Exit(1) + } + cmdAIAsk(cfg, os.Args[3]) default: - fmt.Println("usage: csax audit tail|search [args]") + fmt.Println(`usage: csax audit tail|search [args] | ask ""`) os.Exit(1) } case "oauth": cfg := mustLoadConfig() if len(os.Args) < 3 { - fmt.Println("usage: csax oauth providers list | test ") + fmt.Println("usage: csax oauth providers list | test | users get | unlink --provider ") os.Exit(1) } switch os.Args[2] { case "providers": - if len(os.Args) < 4 || os.Args[3] != "list" { - fmt.Println("usage: csax oauth providers list [--json]") + if len(os.Args) < 4 { + fmt.Println("usage: csax oauth providers list [--json] | add ") + os.Exit(1) + } + switch os.Args[3] { + case "list": + fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[4:]) + cmdOAuthProvidersList(cfg, *jsonOut) + case "add": + if len(os.Args) < 5 { + fmt.Println("usage: csax oauth providers add ") + os.Exit(1) + } + cmdOAuthProvidersAdd(cfg, os.Args[4]) + default: + fmt.Println("usage: csax oauth providers list [--json] | add ") os.Exit(1) } - fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError) - jsonOut := fs.Bool("json", false, "output as JSON") - fs.Parse(os.Args[4:]) - cmdOAuthProvidersList(cfg, *jsonOut) case "test": if len(os.Args) < 4 { fmt.Println("usage: csax oauth test ") os.Exit(1) } cmdOAuthTest(cfg, os.Args[3]) + case "users": + if len(os.Args) < 5 || os.Args[3] != "get" { + fmt.Println("usage: csax oauth users get [--json]") + os.Exit(1) + } + db := mustConnect(cfg) + defer db.Close() + fs := flag.NewFlagSet("oauth users get", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[5:]) + cmdOAuthUsersGet(db, os.Args[4], *jsonOut) + case "unlink": + if len(os.Args) < 4 { + fmt.Println("usage: csax oauth unlink --provider ") + os.Exit(1) + } + fs := flag.NewFlagSet("oauth unlink", flag.ExitOnError) + providerFlag := fs.String("provider", "", "provider to unlink (required)") + fs.Parse(os.Args[4:]) + db := mustConnect(cfg) + defer db.Close() + cmdOAuthUnlink(db, os.Args[3], *providerFlag) + case "config": + cmdOAuthConfig(cfg) default: - fmt.Println("usage: csax oauth providers list | test ") + fmt.Println("usage: csax oauth providers list | test | users get | unlink --provider | config") os.Exit(1) } case "ai": cfg := mustLoadConfig() if len(os.Args) < 3 { - fmt.Println(`usage: csax ai query "" | logs "" | audit`) + fmt.Println(`usage: csax ai query "<...>" | logs "<...>" | audit | config | anomalies scan [--since 24h] | ask "<...>"`) os.Exit(1) } switch os.Args[2] { @@ -231,11 +297,38 @@ func main() { cmdAILogs(cfg, os.Args[3]) case "audit": cmdAIAudit(cfg) + case "config": + cmdAIConfig(cfg) + case "anomalies": + if len(os.Args) < 4 || os.Args[3] != "scan" { + fmt.Println("usage: csax ai anomalies scan [--since 24h]") + os.Exit(1) + } + fs := flag.NewFlagSet("ai anomalies scan", flag.ExitOnError) + since := fs.String("since", "24h", "how far back to look") + fs.Parse(os.Args[4:]) + cmdAIAnomaliesScan(cfg, *since) + case "ask": + if len(os.Args) < 4 { + fmt.Println(`usage: csax ai ask ""`) + os.Exit(1) + } + cmdAIAsk(cfg, os.Args[3]) default: - fmt.Println(`usage: csax ai query "" | logs "" | audit`) + fmt.Println(`usage: csax ai query "<...>" | logs "<...>" | audit | config | anomalies scan [--since 24h] | ask "<...>"`) os.Exit(1) } + // `audit ask` is an alias for `ai ask`, handled inside the real + // top-level `case "audit":` block above (not duplicated here — + // Go doesn't allow two cases with the same value in one switch). + + case "doctor": + cfg := mustLoadConfig() + db := mustConnect(cfg) + defer db.Close() + cmdDoctor(cfg, db) + case "stats": cfg := mustLoadConfig() db := mustConnect(cfg) From 2db7ef8364092ebfcd69e82b97e36c98992a5718 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:21:09 +0100 Subject: [PATCH 18/19] docs: document new ai/oauth/doctor commands and privacy behavior Updates the README for the new surface area: audit ask, oauth providers add / users get / unlink / config, ai anomalies scan / ask / config, and doctor. Documents the BASE_URL vs FRONTEND_URL distinction (a real mixup that broke a deployment during testing), the FRONTEND_URL env var, and the interactive config wizards. Adds a privacy note that ai ask/ai logs/ai anomalies scan redact emails and IPs to stable placeholders before anything is sent to the AI provider, while the full unredacted data is still printed to the terminal. Design notes now cover table.go/spinner.go output behavior, doctor reusing existing checks, the --json coverage gap, and the flat-package-main structural gap that was deliberately deferred. --- README.md | 102 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c722bcb..389cf6b 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,12 @@ Admin CLI for CrydenSync — manage users, sessions, and audit logs from the terminal. Not end-user facing; this is for developers/operators running a CrydenSync-backed app, same as `psql` is for a database, not for the app's own users. -## Installation +## Install ```bash go install github.com/crydensync/csax@latest ``` -If you get `command not found` (or, on Windows, `'csax' is not recognized`) after this, `csax` installed correctly — it's just not on your `PATH` yet. Fix: - -**Linux / macOS / Termux:** -```bash -echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc # use ~/.zshrc if that's your shell -source ~/.bashrc -``` - -**Windows (PowerShell):** -```powershell -setx PATH "$env:Path;$(go env GOPATH)\bin" -``` -Then open a new terminal window for it to take effect. - -Confirm it worked: -```bash -csax version -``` - ## Setup ```bash @@ -48,11 +29,19 @@ csax sessions revoke --user csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] # system-wide, across all users -csax oauth providers list [--json] # which providers have client ID/secret set +csax audit ask "" # alias for `ai ask` — natural-language question over your own data +csax oauth providers list [--json] | add # add: interactive, configures one provider's credentials csax oauth test # round-trips the provider's real endpoints before a live user hits it +csax oauth users get [--json] # which providers an account has linked +csax oauth unlink --provider # force-unlink a provider from an account +csax oauth config # interactive: BASE_URL/FRONTEND_URL + both providers at once csax ai query "" [--json] # read-only, allowlisted lookups over users/sessions/audit_events csax ai logs "" # natural-language audit event search, summarized — never acts +csax ai anomalies scan [--since 24h] # fixed-prompt `ai logs`, looks for credential-stuffing/abuse patterns +csax ai ask "" # direct question over any allowlisted entity, answered in plain language csax ai audit # flags likely misconfigurations from a fixed checklist — never auto-applies anything +csax ai config # interactive: provider/model/API key + auto-creates the read-only DB role +csax doctor # one-shot health check: DB, migrations, AI config, OAuth config csax stats # total users, active sessions, etc. csax health csax version @@ -60,23 +49,37 @@ csax version ## Optional: OAuth admin commands -`oauth providers list` and `oauth test` read the SAME env vars `api` +`oauth providers list`/`add`/`test` all read the SAME env vars `api` uses for its own OAuth config — they check the real values production uses, not a separate csax-only copy: ``` -BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET +BASE_URL, FRONTEND_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET ``` -`oauth users get`/`oauth unlink` (managing which providers a specific -user has linked) are designed but not built yet — they need two new -`cryden` engine facades that don't exist as of this CLI's current -`cryden` dependency version. +Run `csax oauth config` for an interactive setup of all of the above, +or `csax oauth providers add ` to add just one provider once +`BASE_URL`/`FRONTEND_URL` are already set. + +**`BASE_URL` vs `FRONTEND_URL` — a mixup that actually happened during +testing:** `BASE_URL` is your BACKEND's own public URL, since that's +where the `/api/oauth/.../callback` route lives. If your frontend and +backend are on different domains (e.g. Vercel + Railway), `BASE_URL` +is the Railway one, not the Vercel one. Both wizards spell this out +explicitly rather than assuming it's obvious. + +`oauth users get`/`oauth unlink` manage which providers a specific +user has linked. These don't call any `cryden` engine method — they +query `oauth_identities` directly via SQL, same pattern as `users +list`/`stats` below. No engine change was needed or made to support +them. ## Optional: AI-assisted admin commands -`ai query`/`ai logs`/`ai audit` need their own config, on top of the -usual `.env`: +Run `csax ai config` for an interactive setup — it prompts for the +provider/model/API-key-env-name, and can create the read-only +database role for you directly (see below), rather than handing you a +script to run by hand. Or set these in `.env` yourself: ``` AI_PROVIDER=groq # or "openrouter" — both speak the same OpenAI-compatible chat completions shape @@ -85,10 +88,17 @@ AI_MODEL=... # any model id your chosen provider serves READONLY_DATABASE_URL=... # a SEPARATE connection string, pointing at a Postgres role that is physically read-only ``` -`READONLY_DATABASE_URL` is the real safety boundary for `ai query`/`ai -logs` — even a bug in the underlying allowlist validation can't cause -a write if the credential itself is incapable of one. Don't point it -at the same role `DATABASE_URL` uses. +`READONLY_DATABASE_URL` is the real safety boundary for `ai +query`/`ai logs`/`ai anomalies scan`/`ai ask` — even a bug in the +underlying allowlist validation can't cause a write if the credential +itself is incapable of one. Don't point it at the same role +`DATABASE_URL` uses. + +`csax ai config`'s role setup works against ANY Postgres provider, not +just Supabase — it only appends Supabase's `.` +username suffix when it actually detects a Supabase pooler host; a +self-hosted Postgres, Neon, RDS, etc. all just get the plain role +name. `ai audit` works even without any AI config — the checklist itself is plain Go, not model-generated; the model is only used to add a short @@ -96,17 +106,33 @@ prioritization narrative on top, which is skipped silently if AI isn't configured. None of the `ai` commands ever execute an action a finding surfaces — -`ai audit` never applies a fix, and `ai logs` never revokes a session -or locks an account it flags. Anything like that is a suggestion in -the output text, run yourself as a separate, explicit command. +`ai audit` never applies a fix, `ai logs`/`ai anomalies scan` never +revoke a session or lock an account they flag. Anything like that is a +suggestion in the output text, run yourself as a separate, explicit +command. + +**Privacy note on `ai ask`/`ai logs`/`ai anomalies scan`:** emails and +IPs are redacted to stable per-query placeholders (`email_1`, `ip_2`) +before anything is sent to your configured AI provider for +summarization — patterns like repetition and clustering are still +visible to the model, but real values never leave your infrastructure +through that path. The full, unredacted data is still what gets +printed to your own terminal afterward. This is a default `csax` +ships with, not something separately audited — worth knowing if +you're evaluating this for a deployment with stricter data-handling +requirements. ## Design notes -- Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI. +- Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`, `oauth users get`, `oauth unlink`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI. - `MIGRATIONS_DIR` (default `./migrations`) should point at a folder containing both CrydenSync's own migration files and your app's own — `csax migrate` treats them the same, just files matching `*.up.sql`/`*.down.sql`, run in filename order. - No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself. This is also why `oauth`/`ai` help text lives in `usage()` by hand rather than being generated — there's no framework here to generate it from. - `ai` commands are the one part of csax that calls out to something other than this deployment's own Postgres — the `ai.LLMProvider` interface ships zero implementations upstream in `cryden`; csax brings its own (`llmProvider` in `aiprovider.go`, an OpenAI-chat-completions-shaped client — works with Groq, OpenRouter, or any other provider speaking that same shape via `AI_PROVIDER`), same pattern as `notify.EmailSender`. -- Colored output by default (auto-disabled when not writing to a real terminal). Commands returning structured data (`users get`, `users list`, `sessions list`) support `--json` for scripting. +- Colored, box-drawing table output by default (`table.go`) — auto-disabled when not writing to a real terminal, or when `NO_COLOR` is set (see https://no-color.org). Long values (UUIDs, emails) are truncated with `…` rather than blowing up column widths. Audit event types get semantic color: red for anything `*_failed`/`*_reuse_detected`/`*_locked`, green for `*_success`/`*_linked`/`*_unlocked`. +- A terminal spinner (`spinner.go`) shows during any real network/DB round trip — AI provider calls, the OAuth endpoint reachability check, and the read-only role's connection test. Skipped automatically in the same non-TTY/`NO_COLOR` cases as the table output, so scripted/piped usage never sees spinner frames mixed into captured output. +- `csax doctor` aggregates the existing `csax health` and `csax ai audit` checks plus an OAuth config summary into one command — it calls the same underlying functions those commands use rather than re-implementing the checks a second time. +- **Known gap, not addressed this release:** `--json` is only implemented on a handful of commands (`users get`, `users list`, `oauth providers list`, `oauth users get`, `ai query`). Retrofitting it consistently across every command (`ai logs`, `ai ask`, `ai anomalies scan`, `ai audit`, `doctor`, `stats`, `audit tail`/`search`) is real, not-yet-done work — each needs its own JSON-serializable shape, not just a flag. +- **Known gap, not addressed this release:** this is still one flat `package main` across ~20 files. Splitting into proper subpackages (`internal/oauth`, `internal/ai`, `internal/config`, ...) is a real structural change touching nearly every file's imports at once — deliberately NOT attempted alongside this release's feature work, since it's a much higher-risk change to make at the same time as everything else here, and much harder to review as one large diff. Worth doing as its own dedicated pass, with nothing else changing in that same commit. ## License From 2e3c0ba41684f6b952a6924e9675f8c42b419924 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Tue, 1 Sep 2026 22:21:10 +0100 Subject: [PATCH 19/19] chore: clean up trailing newlines in gitignore and migration Adds the missing newline at EOF to .gitignore (git was warning about it) and removes the stray trailing blank line in the 0002_oauth_identities migration. Pure whitespace hygiene, no content change. --- .gitignore | 2 +- migrations/0002_oauth_identities.up.sql | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d5fb58f..a5dc8df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .env -csax \ No newline at end of file +csax diff --git a/migrations/0002_oauth_identities.up.sql b/migrations/0002_oauth_identities.up.sql index 408be77..a433f58 100644 --- a/migrations/0002_oauth_identities.up.sql +++ b/migrations/0002_oauth_identities.up.sql @@ -13,4 +13,3 @@ CREATE TABLE oauth_identities ( ); CREATE INDEX idx_oauth_identities_user_id ON oauth_identities(user_id); -