Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions internal/cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// Package doctor implements `atl doctor`, a diagnostic command that checks
// auth and config health without touching the network.
package doctor

import (
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/enthus-appdev/atl-cli/internal/auth"
"github.com/enthus-appdev/atl-cli/internal/config"
"github.com/enthus-appdev/atl-cli/internal/iostreams"
"github.com/enthus-appdev/atl-cli/internal/output"
)

// Options holds the options for the doctor command.
type Options struct {
IO *iostreams.IOStreams
JSON bool
}

// NewCmdDoctor creates the doctor command.
func NewCmdDoctor(ios *iostreams.IOStreams) *cobra.Command {
opts := &Options{IO: ios}

cmd := &cobra.Command{
Use: "doctor",
Short: "Diagnose auth and config health",
Long: `Check the health of your atl setup: config file, OAuth app credentials
(and which layer supplies them), and per-host token state.

Credential values are shown partially masked so you can cross-check them
against your OAuth app in the Atlassian developer console. The client secret
is never shown beyond its last 4 characters.`,
Example: ` atl doctor
atl doctor --json`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor(opts)
},
}

cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON")

return cmd
}

// Check is one diagnostic result.
type Check struct {
Name string `json:"name"`
Status string `json:"status"` // "ok", "warn", or "error"
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}

// Report is the full doctor output.
type Report struct {
ConfigFile string `json:"config_file"`
CredentialSource string `json:"credential_source,omitempty"`
ClientIDMasked string `json:"client_id_masked,omitempty"`
ClientSecretHint string `json:"client_secret_hint,omitempty"`
Checks []Check `json:"checks"`
}

func runDoctor(opts *Options) error {
report := &Report{ConfigFile: config.ConfigFile()}

cfg := checkConfig(report)
checkCredentials(report, cfg)
checkHosts(report, cfg)

if opts.JSON {
return output.JSON(opts.IO.Out, report)
}
return renderText(opts, report)
}

func (r *Report) add(status, name, message, hint string) {
r.Checks = append(r.Checks, Check{Name: name, Status: status, Message: message, Hint: hint})
}

// checkConfig verifies the config file loads and is not world-readable.
// Returns the loaded config (never nil; falls back to an empty config so the
// remaining checks can still report what they know).
func checkConfig(r *Report) *config.Config {
path := config.ConfigFile()
info, statErr := os.Stat(path)
switch {
case os.IsNotExist(statErr):
r.add("warn", "config file", fmt.Sprintf("%s does not exist", path),
"Run 'atl auth login' to get started")
case statErr != nil:
r.add("error", "config file", fmt.Sprintf("cannot stat %s: %v", path, statErr), "")
case info.Mode().Perm()&0o077 != 0:
r.add("warn", "config file permissions",
fmt.Sprintf("%s is readable by other users (mode %o)", path, info.Mode().Perm()),
fmt.Sprintf("Run 'chmod 600 %s'", path))
default:
r.add("ok", "config file", fmt.Sprintf("%s (mode %o)", path, info.Mode().Perm()), "")
}

cfg, err := config.Load()
if err != nil {
r.add("error", "config parse", err.Error(), "")
return &config.Config{}
}
return cfg
}

// checkCredentials reports which layer supplies the OAuth app credentials and
// shows them partially masked for cross-checking. The client ID is not a
// secret in OAuth 2.0 (it is visible in browser URLs during login), so first
// and last characters are shown. The client secret IS sensitive: only its
// length and last 4 characters are ever revealed.
func checkCredentials(r *Report, cfg *config.Config) {
var configID, configSecret string
if cfg.OAuth != nil {
configID, configSecret = cfg.OAuth.ClientID, cfg.OAuth.ClientSecret
}
id, secret, source := auth.ResolveClientCredentials(configID, configSecret)

// A lone env var half is silently skipped by the resolver — surface it,
// since it usually means a typo'd or forgotten variable.
envID, envSecret := os.Getenv("ATLASSIAN_CLIENT_ID"), os.Getenv("ATLASSIAN_CLIENT_SECRET")
if (envID == "") != (envSecret == "") {
r.add("warn", "environment variables",
"only one of ATLASSIAN_CLIENT_ID / ATLASSIAN_CLIENT_SECRET is set; the environment layer is being ignored",
"Set both variables, or unset the stray one")
}

if id == "" || secret == "" {
r.add("error", "oauth credentials", "not configured in any layer",
"Run 'atl auth set-credentials' (keychain) or set ATLASSIAN_CLIENT_ID/ATLASSIAN_CLIENT_SECRET")
return
}

r.CredentialSource = string(source)
r.ClientIDMasked = maskID(id)
r.ClientSecretHint = secretHint(secret)

msg := fmt.Sprintf("from %s — client ID %s, client secret %s", source, r.ClientIDMasked, r.ClientSecretHint)
if source == auth.SourceConfig {
r.add("warn", "oauth credentials", msg,
"Credentials sit in plaintext in config.yaml; prefer 'atl auth set-credentials' to move them into the OS keychain")
} else {
r.add("ok", "oauth credentials", msg, "")
}

// A complete pair in a shadowed lower layer diverging from the active one
// is a classic "why is my login using the wrong app" trap.
if source != auth.SourceConfig && configID != "" && configSecret != "" && configID != id {
r.add("warn", "shadowed credentials",
fmt.Sprintf("config file holds a DIFFERENT client ID (%s) than the active %s layer", maskID(configID), source),
"Remove the stale pair from config.yaml if it is no longer used")
}
if kc, ok := auth.GetClientCredentials(); ok && source == auth.SourceEnv && kc.ClientID != id {
r.add("warn", "shadowed credentials",
fmt.Sprintf("OS keychain holds a DIFFERENT client ID (%s) than the environment variables", maskID(kc.ClientID)),
"Unset the env vars or update the keychain entry so they agree")
}
}

// checkHosts verifies host configuration and per-host token state.
func checkHosts(r *Report, cfg *config.Config) {
if len(cfg.Hosts) == 0 {
r.add("warn", "hosts", "no hosts configured", "Run 'atl auth login'")
return
}

if cfg.CurrentHost == "" {
r.add("warn", "current host", "no current host set",
"Run 'atl config use-context <host>'")
} else if cfg.GetHost(cfg.CurrentHost) == nil {
r.add("error", "current host",
fmt.Sprintf("current host %q is not in the hosts list", cfg.CurrentHost),
"Run 'atl config use-context <host>' with a configured host")
} else {
r.add("ok", "current host", cfg.CurrentHost, "")
}

for hostname, hostCfg := range cfg.Hosts {
name := "token: " + hostname
if hostCfg.CloudID == "" {
r.add("warn", "host: "+hostname, "missing cloud ID", "Re-run 'atl auth login'")
}
tokens, err := auth.GetToken(hostname)
switch {
case err != nil:
r.add("error", name, fmt.Sprintf("cannot read token: %v", err), "")
case tokens == nil:
r.add("error", name, "no token stored", "Run 'atl auth login'")
case tokens.RefreshToken == "" && tokens.IsExpired():
r.add("error", name, "token expired and no refresh token stored", "Run 'atl auth login'")
case tokens.IsExpired():
r.add("warn", name, fmt.Sprintf("token expired at %s (will auto-refresh on next use)",
tokens.ExpiresAt.Format(time.RFC3339)), "Or run 'atl auth refresh' now")
default:
r.add("ok", name, fmt.Sprintf("valid until %s", tokens.ExpiresAt.Format(time.RFC3339)), "")
}
}

// Token files for hosts no longer in the config hold live refresh tokens
// but are invisible to every other command.
stored, err := auth.ListStoredHosts()
if err == nil {
for _, h := range stored {
if cfg.GetHost(h) == nil {
r.add("warn", "orphaned token: "+h, "token file exists but host is not configured",
fmt.Sprintf("Run 'atl auth logout --hostname %s' to remove it", h))
}
}
}
}

// maskID shows enough of a client ID to identify it without printing it whole.
func maskID(s string) string {
if len(s) < 10 {
return "****"
}
return s[:4] + "…" + s[len(s)-4:]
}

// secretHint identifies a secret by length and last 4 characters only —
// enough to cross-check against the developer console, useless to reconstruct.
func secretHint(s string) string {
if len(s) < 8 {
return fmt.Sprintf("(%d chars)", len(s))
}
return fmt.Sprintf("(%d chars, ends …%s)", len(s), s[len(s)-4:])
}

func renderText(opts *Options, r *Report) error {
symbols := map[string]string{
"ok": output.Success.Render("✓"),
"warn": output.Warning.Render("!"),
"error": output.Error.Render("✗"),
}

var warns, errs int
for _, c := range r.Checks {
fmt.Fprintf(opts.IO.Out, "%s %s: %s\n", symbols[c.Status], output.Bold.Render(c.Name), c.Message)
if c.Hint != "" {
fmt.Fprintf(opts.IO.Out, " %s\n", c.Hint)
}
switch c.Status {
case "warn":
warns++
case "error":
errs++
}
}

fmt.Fprintln(opts.IO.Out)
switch {
case errs > 0:
fmt.Fprintf(opts.IO.Out, "%s\n", output.Error.Render(fmt.Sprintf("%d error(s), %d warning(s)", errs, warns)))
return fmt.Errorf("doctor found %d error(s)", errs)
case warns > 0:
fmt.Fprintf(opts.IO.Out, "%s\n", output.Warning.Render(fmt.Sprintf("%d warning(s)", warns)))
default:
fmt.Fprintf(opts.IO.Out, "%s\n", output.Success.Render("All checks passed"))
}
return nil
}
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
boardCmd "github.com/enthus-appdev/atl-cli/internal/cmd/board"
configCmd "github.com/enthus-appdev/atl-cli/internal/cmd/config"
confluenceCmd "github.com/enthus-appdev/atl-cli/internal/cmd/confluence"
doctorCmd "github.com/enthus-appdev/atl-cli/internal/cmd/doctor"
issueCmd "github.com/enthus-appdev/atl-cli/internal/cmd/issue"
jiraCmd "github.com/enthus-appdev/atl-cli/internal/cmd/jira"
smCmd "github.com/enthus-appdev/atl-cli/internal/cmd/sm"
Expand Down Expand Up @@ -61,6 +62,7 @@ Environment variables:
cmd.AddCommand(jiraCmd.NewCmdJira(ios))
cmd.AddCommand(confluenceCmd.NewCmdConfluence(ios))
cmd.AddCommand(configCmd.NewCmdConfig(ios))
cmd.AddCommand(doctorCmd.NewCmdDoctor(ios))
cmd.AddCommand(newVersionCmd(ios, version, commit, date))
cmd.AddCommand(newCompletionCmd(ios))

Expand Down